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 error = objectMapper.readValue(body, new TypeReference<>() { }); + if (error != null && !error.successful()) throw new BizException(ResultCode.of(error.code()), error.message()); + } catch (BizException exception) { throw exception; } catch (Exception ignored) { } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应"); + } + HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); + String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + if (StringUtils.hasText(disposition)) headers.set(HttpHeaders.CONTENT_DISPOSITION, disposition); + headers.setContentLength(body.length); return ResponseEntity.ok().headers(headers).body(body); + } +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 9c57f40c6..51a5e694d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -348,6 +348,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仓冻结的可打印托盘码'; + -- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 8dce29cbf..773d54235 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -74,6 +74,23 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}} responses: '200': {$ref: '#/components/responses/PreloadConfirmationResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare: + post: + summary: 生成并查询散货托盘码清单 + description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。 + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] + responses: + '200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}} + /api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print: + post: + summary: 打印散货托盘码 + description: 仓码和张数均为空时打印全部;均有值时打印该 FBA 仓从第1张开始的指定数量,用于补打。 + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/PalletLabelPrintRequest'}}} + responses: + '200': {description: 100mm × 100mm 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}} /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 @@ -430,3 +447,9 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略} + PalletLabelPrintRequest: + type: object + additionalProperties: false + properties: + destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} + count: {type: integer, minimum: 1, maximum: 999, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index dd2013b77..2926675eb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -9,6 +9,7 @@ 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.LoadingCartonManifestMapper.LabelRow; +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,14 +31,19 @@ class LoadingCompletedOutboxServiceTest { private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); + private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class); + private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); + ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels); + ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(palletLabels.labels(anyLong())).thenReturn(List.of()); when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( label(101, "BOX-101"), label(102, "BOX-102"), label(202, "BOX-202"), label(302, "BOX-302"))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java new file mode 100644 index 000000000..46759a201 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingPalletLabelServiceTest { + @Test + void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() { + LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class); + LoadingPalletLabelService service = new LoadingPalletLabelService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + List saved = new ArrayList<>(); + when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"), + source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"), + source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP"))); + when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved)); + when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; }); + + var manifest = service.prepare(10L, 20L); + + assertThat(manifest.totalCount()).isEqualTo(4); + assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount()) + .containsExactly("LAX9:1", "ONT8:3"); + assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode) + .containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001", + "MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003"); + } + + private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) { + var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1"); + row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L); + row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000); + row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination); + row.setDestinationWarehouseName(destination); row.setPalletCount(pallets); + row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 991267f24..ede826990 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -142,13 +142,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { throw new ResponseStatusException(HttpStatus.CONFLICT, "Reserved pallet customer binding is invalid"); } - WarehousePalletData pallet = lookupPallet(palletCode).data(); - if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId + WarehousePalletReference pallet; + try { + pallet = reference(lookupPallet(palletCode).data()); + } catch (ResponseStatusException exception) { + if (exception.getStatusCode().value() != 404) throw exception; + // 已生成计划可能保留早期展示码;确认过的资源绑定仍是唯一可信物理身份。 + pallet = resolvePallet(palletId); + } + if (pallet.palletId() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId || !"OCCUPIED".equals(pallet.status())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Scanned pallet does not match the reserved inbound plan resource"); } - return reference(pallet); + return pallet; } @Override diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 88e900246..0a7337a72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -55,7 +55,8 @@ public interface WarehouseResourceGateway { java.math.BigDecimal maxLoadKg, String status) { } record PalletBatchEnsure(long warehouseId, String batchKey, List items) { } record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo, - int sequenceNo, String palletType, String fbaWarehouseCode) { } + int sequenceNo, String palletType, String fbaWarehouseCode, + String palletCode) { } record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { } record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { } record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index b78c9b395..3f3767265 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -117,7 +117,7 @@ public class ContainerInboundPlanConversionCoordinator { preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD", - item.fbaWarehouseCode())).toList()); + item.fbaWarehouseCode(), item.palletCode())).toList()); WarehouseResourceGateway.PalletBatchResponse reservation; try { reservation = warehouse.ensurePalletBatch(ensure); @@ -207,10 +207,15 @@ public class ContainerInboundPlanConversionCoordinator { .flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) { long planId = IdWorker.getId(); String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); + List sourcePalletNos = plans.sourcePalletNos( + order.customerId(), Long.parseLong(order.orderId()), order.orderType()); + if (sourcePalletNos.size() != order.suggestedPalletCount()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致"); + } for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, - trimToNull(order.fbaWarehouseCode()))); + trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1))); } } Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); @@ -373,5 +378,5 @@ public class ContainerInboundPlanConversionCoordinator { public record Preparation(long warehouseId, List items) { } public record PreparedItem(String customerId, long orderId, long planId, String planNo, int localSequenceNo, int globalSequenceNo, String businessKey, - String fbaWarehouseCode) { } + String fbaWarehouseCode, String palletCode) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index f28059c3a..7fe420dee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -27,6 +27,7 @@ import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -65,8 +66,16 @@ public class InboundPlanService { @Resource private DropshipOrderService dropshipOrders; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private LastlegOrderCommandRecordMapper commandMapper; + @Resource private LastlegSourcePalletMapper sourcePalletMapper; @Resource private ObjectMapper objectMapper; + public List sourcePalletNos(String customerId, long orderId, String orderType) { + return "ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipOrders.palletSnapshots(customerId, orderId).stream() + .map(DropshipPalletSnapshot::sourcePalletNo).toList() + : sourcePalletMapper.palletNos(customerId, orderId); + } + @Transactional public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, String operatorId) { @@ -393,6 +402,10 @@ public class InboundPlanService { PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())).toList()) : null; + if (allocation != null && sourcePalletNos != null && !sourcePalletNos.isEmpty() + && sourcePalletNos.size() != allocation.pallets().size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与尾程计划托盘数不一致"); + } if (wholePallets != null) validateWholePallets(orders, wholePallets); plan.setPalletCount(manual != null ? manual.palletCount() : wholePallets == null ? allocation.pallets().size() : wholePallets.size()); @@ -479,6 +492,7 @@ public class InboundPlanService { List pendingIndexes = new ArrayList<>(); List> snapshots = new ArrayList<>(); List> wholePallets = new ArrayList<>(); + List> standardPalletNos = new ArrayList<>(); for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); InboundPlan existing = existingPlans.get(index); @@ -517,6 +531,11 @@ public class InboundPlanService { validateWholePallets(orders, sourcePallets); } wholePallets.add(sourcePallets); + List standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of() + : sourcePalletMapper.palletNos(ref.customerId(), ref.orderId()); + if (sourcePallets == null && standardNos.isEmpty()) throw new ResponseStatusException( + HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照"); + standardPalletNos.add(standardNos); } requireSingleWarehouseId(warehouseIds); @@ -531,7 +550,7 @@ public class InboundPlanService { Map reserved = prepared == null ? Map.of() : prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo, PreparedPallet::reservation)); - created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(), + created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex), wholePallets.get(pendingIndex), null, null, prepared, reserved)); } List result = new ArrayList<>(refs.size()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java new file mode 100644 index 000000000..7304ff5bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.mapper; + +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 LastlegSourcePalletMapper { + @Insert(""" + INSERT INTO ll_order_source_pallet + (id,customer_id,order_id,source_pallet_no,sequence_no,source_event_id,created_at) + VALUES (#{id},#{customerId},#{orderId},#{palletNo},#{sequenceNo},#{eventId},CURRENT_TIMESTAMP(3)) + """) + int insert(@Param("id") long id, @Param("customerId") String customerId, + @Param("orderId") long orderId, @Param("palletNo") String palletNo, + @Param("sequenceNo") int sequenceNo, @Param("eventId") String eventId); + + @Select(""" + SELECT source_pallet_no FROM ll_order_source_pallet + WHERE customer_id=#{customerId} AND order_id=#{orderId} + ORDER BY sequence_no,id + """) + List palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 47aadc593..6ff108938 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -44,6 +44,7 @@ public class LoadingCompletedConsumer { @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; @Resource private ContainerShippingGateMapper shippingGates; + @Resource private LastlegSourcePalletMapper sourcePallets; @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { @@ -273,6 +274,21 @@ public class LoadingCompletedConsumer { } createInboundOrderSnapshot(event, payload, source, order, orderHash); + if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) { + List pallets = source.pallets() == null ? List.of() : source.pallets(); + Set palletNos = new HashSet<>(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + String palletNo = pallets.get(palletIndex).palletNo(); + if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) { + throw new IllegalArgumentException("散货来源托盘码为空、重复或过长"); + } + sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId()); + } + if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) { + throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致"); + } + } + for(var item:source.items()) { LastlegOrderDetail detail=new LastlegOrderDetail(); detail.setCustomerId(customerId); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a85b17d75..b7187ac84 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -112,6 +112,22 @@ CREATE TABLE IF NOT EXISTS ll_order ( KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id) ) ENGINE=InnoDB COMMENT='尾程订单主表'; +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='头程交接的不可变托盘码快照'; + + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 823f6ef10..2b3a8838d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; @@ -98,6 +99,23 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":31,"palletCode":"PLT-US-LAX-01-31", + "warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3, + "reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + var result = gateway.resolveReservedPallet("IP-ACY2-20260909-166786-P001", "11", 21L, 31L, 41L, 51L); + + assertThat(result.palletId()).isEqualTo(31L); + server.verify(); + } + @Test void resolveReservedRejectsDifferentPhysicalBinding() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) @@ -203,7 +221,7 @@ class HttpWarehouseResourceGatewayTest { var result = gateway.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(31L, "BATCH-1", List.of(new WarehouseResourceGateway.PalletBatchItemRequest( - 100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8")))); + 100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8", "CONT-ONT8-001")))); assertThat(result.batchId()).isEqualTo(41L); assertThat(result.items().get(0).resourceLockId()).isEqualTo(71L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index c24c3f086..084121a52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -117,7 +117,7 @@ class ContainerInboundPlanConversionCoordinatorTest { @Test void reservationMustMatchEveryStableItemIdentity() throws Exception { var expected = new ContainerInboundPlanConversionCoordinator.PreparedItem( - "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); + "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001"); var preparation = new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(expected)); var response = new WarehouseResourceGateway.PalletBatchResponse(41L, "BATCH-1", "RESERVED", 1, false, List.of(new WarehouseResourceGateway.PalletBatchItem( @@ -239,6 +239,8 @@ class ContainerInboundPlanConversionCoordinatorTest { when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) .thenReturn("IP-FBA", "IP-OVERSEAS"); + when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001")); + when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001")); ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", 1L, 2L, BOOKING_ID, "FIRST-KEY", request, @@ -291,7 +293,7 @@ class ContainerInboundPlanConversionCoordinatorTest { private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception { var item = new ContainerInboundPlanConversionCoordinator.PreparedItem( - "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); + "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001"); command.setPreparationJson(objectMapper.writeValueAsString( new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item)))); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java index a15574a96..1cf904435 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -23,7 +23,8 @@ public final class WarehousePalletBatchRequests { @NotBlank @Size(max = 128) String inboundPlanNo, @Positive int sequenceNo, @NotBlank @Size(max = 32) String palletType, - @Size(max = 128) String fbaWarehouseCode + @Size(max = 128) String fbaWarehouseCode, + @NotBlank @Size(max = 128) String palletCode ) { } public record Confirm( diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index d649a00c0..f0de25ab7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -80,7 +80,7 @@ public class WarehousePalletBatchService { List created = new ArrayList<>(); for (WarehousePalletBatchRequests.Item requested : normalized.items()) { Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, - normalized.warehouseId(), new PalletRequests.SaveRequest(null, requested.palletType(), null, + normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), requested.palletType(), null, BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(), requested.fbaWarehouseCode())); WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, @@ -257,11 +257,13 @@ public class WarehousePalletBatchService { if (item.fbaWarehouseCode() != null) { requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long"); } + String palletCode = required(item.palletCode(), "Pallet code is required"); + requireMax(palletCode, 128, "Pallet code is too long"); if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique"); if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, item.sequenceNo(), palletType, - trim(item.fbaWarehouseCode())); + trim(item.fbaWarehouseCode()), palletCode); }).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList(); return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized); } diff --git a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml index 5b239e44c..970e25e8f 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml @@ -34,7 +34,7 @@ components: schemas: BatchItemRequest: type: object - required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType] + required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType, palletCode] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } businessKey: { type: string, minLength: 1, maxLength: 100 } @@ -42,6 +42,7 @@ components: sequenceNo: { type: integer, minimum: 1 } palletType: { type: string, minLength: 1, maxLength: 32 } fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 } + palletCode: { type: string, minLength: 1, maxLength: 128, description: 头程冻结并已打印的托盘码,海外实体托盘必须原样沿用 } EnsureReservedRequest: type: object required: [warehouseId, batchKey, items] diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java index 49157c4a2..bde250a7b 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -364,8 +364,8 @@ class WarehousePalletBatchServiceTest { private static WarehousePalletBatchRequests.EnsureReserved request() { return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of( - new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8"), - new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9"))); + new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8", "CONT-ONT8-001"), + new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9", "CONT-LAX9-001"))); } private static WarehousePalletBatchEntity batch(long id, String status, int attempt) { From 2b0e66ca7e01ea8ddf321215794533c257d9fc9e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:35:47 +0800 Subject: [PATCH 13/42] test: cover pallet label loading-list paths --- .../controller/LoadingListControllerContractTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index aca53edea..4f52501c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -75,6 +75,9 @@ class LoadingListControllerContractTest { .contains("PDA任务尚未领取且无任何物理作业事实") .contains("/loading-plans/{planId}/confirm") .contains("/loading-plans/{planId}/lifecycle") + .contains("/loading-plans/{planId}/pallet-labels/prepare") + .contains("/loading-plans/{planId}/pallet-labels/print") + .contains("柜号-FBA仓编码-三位序号") .contains("不得以错误文案或 A0404 猜测阶段") .contains("confirmationStatus") .contains("canConfirm") @@ -123,6 +126,8 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-plans/{planId}/lifecycle", "/api/platform/v1/first-leg/loading-plans/{planId}/transport", "/api/platform/v1/first-leg/loading-plans/{planId}/confirm", + "/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare", + "/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print", "/api/platform/v1/first-leg/loading-lists", "/api/platform/v1/first-leg/loading-lists/{planId}"); Map components = stringMap(document.get("components")); @@ -132,7 +137,7 @@ class LoadingListControllerContractTest { "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", - "LifecycleResolution", "LifecycleResolutionEnvelope"); + "LifecycleResolution", "LifecycleResolutionEnvelope", "PalletLabelPrintRequest"); assertThat(properties(schemas, "LifecycleResolution")).containsOnlyKeys( "planId", "containerBookingId", "warehouseId", "planNo", "planStatus", "detailResource", "requiredPermission"); From 88b489eb7789cca6ca2f00a13947d9ef0ddf6eeb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:42:26 +0800 Subject: [PATCH 14/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 | 597 ++++++++++-------- firstleg/docs/terminal-drayage-mail.md | 8 +- .../TerminalDrayageMailDispatcher.java | 5 +- .../service/TerminalDrayageMailService.java | 9 +- firstleg/src/main/resources/application.yml | 1 - .../openapi/terminal-drayage-v1.yaml | 2 +- .../TerminalDrayageMailDispatcherTest.java | 7 +- .../TerminalDrayageMailServiceTest.java | 12 + .../service/TerminalDrayageServiceTest.java | 11 +- .../service/InboundPlanServiceTest.java | 3 + .../finance-full-fixtures-contract.test.mjs | 7 +- .../finance-full-fixtures.mjs | 88 ++- ...house-public-master-data-contract.test.mjs | 27 +- .../warehouse/dto/WarehouseRequests.java | 2 + .../dto/WarehouseSummaryResponse.java | 3 +- .../warehouse/entity/WarehouseEntity.java | 2 + .../repository/WarehouseRepository.java | 4 +- .../service/WarehouseApplicationService.java | 1 + .../service/WarehouseImportService.java | 22 +- .../service/WarehouseSaveNormalizer.java | 12 +- .../WarehouseSchemaReadinessService.java | 2 +- .../fba-public-addresses.accepted.csv | 566 +++++++++-------- .../fba-public-addresses.rejected.csv | 54 -- .../db/warehouse-public-master-data.md | 18 +- .../db/warehouse-public-master-data.sql | 596 +++++++++-------- warehouse/src/main/resources/db/warehouse.sql | 1 + .../WarehouseSchemaContractTest.java | 2 +- .../WarehouseApplicationServiceTest.java | 23 +- .../service/WarehouseImportServiceTest.java | 19 +- 29 files changed, 1195 insertions(+), 909 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 386de5d13..28737777b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -843,6 +843,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', + internal_code VARCHAR(16) NULL COMMENT 'FBA内部编号,仅FBA仓可配置', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', @@ -6445,8 +6446,8 @@ DELIMITER ; USE baiyue_warehouse; -- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。 --- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。 --- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 +-- 来源:用户提供的 FBA地址表.xls,只读整理后生成。 +-- 完整保留 314 条,拒绝 0 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 -- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。 -- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。 @@ -6770,6 +6771,7 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( address_id_seed BIGINT UNSIGNED NOT NULL, warehouse_code VARCHAR(50) NOT NULL, warehouse_name VARCHAR(120) NOT NULL, + internal_code VARCHAR(16) NULL, state_province VARCHAR(100) NOT NULL, city VARCHAR(100) NOT NULL, postal_code VARCHAR(20) NOT NULL, @@ -6782,281 +6784,339 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; INSERT INTO tmp_wh_fba_public_master_data - (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row) + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,internal_code,state_province,city,postal_code,timezone,address_line1,source_row) VALUES -(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238), -(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237), -(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134), -(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231), -(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125), -(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99), -(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84), -(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141), -(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27), -(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148), -(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174), -(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129), -(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31), -(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147), -(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202), -(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233), -(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232), -(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289), -(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208), -(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207), -(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206), -(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11), -(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221), -(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131), -(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140), -(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220), -(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110), -(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243), -(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193), -(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192), -(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242), -(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241), -(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102), -(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225), -(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223), -(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222), -(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75), -(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191), -(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114), -(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240), -(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181), -(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262), -(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261), -(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260), -(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259), -(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135), -(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123), -(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120), -(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189), -(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182), -(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143), -(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219), -(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217), -(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256), -(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255), -(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254), -(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253), -(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117), -(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176), -(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212), -(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211), -(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89), -(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121), -(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171), -(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151), -(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67), -(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78), -(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68), -(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164), -(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224), -(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188), -(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187), -(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80), -(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87), -(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44), -(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216), -(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215), -(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214), -(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154), -(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142), -(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126), -(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155), -(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3), -(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136), -(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2), -(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152), -(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119), -(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10), -(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6), -(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5), -(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88), -(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276), -(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275), -(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274), -(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173), -(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9), -(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22), -(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69), -(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74), -(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70), -(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40), -(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39), -(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33), -(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21), -(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8), -(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7), -(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4), -(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72), -(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150), -(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178), -(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161), -(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100), -(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247), -(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246), -(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132), -(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162), -(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183), -(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86), -(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268), -(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267), -(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107), -(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291), -(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292), -(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293), -(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294), -(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106), -(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32), -(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157), -(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271), -(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12), -(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115), -(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230), -(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128), -(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200), -(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199), -(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198), -(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197), -(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196), -(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163), -(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118), -(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201), -(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77), -(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270), -(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269), -(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203), -(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94), -(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190), -(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17), -(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299), -(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300), -(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133), -(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19), -(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303), -(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302), -(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301), -(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298), -(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103), -(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195), -(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113), -(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81), -(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127), -(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288), -(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287), -(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236), -(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235), -(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20), -(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234), -(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286), -(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285), -(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307), -(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306), -(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305), -(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304), -(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229), -(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158), -(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46), -(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38), -(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45), -(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49), -(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93), -(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28), -(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71), -(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35), -(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14), -(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146), -(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95), -(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145), -(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16), -(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210), -(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209), -(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144), -(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248), -(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245), -(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244), -(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73), -(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218), -(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149), -(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15), -(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98), -(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185), -(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179), -(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97), -(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60), -(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186), -(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104), -(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266), -(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265), -(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264), -(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263), -(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258), -(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273), -(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257), -(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205), -(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204), -(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295), -(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153), -(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13), -(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184), -(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105), -(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296), -(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109), -(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194), -(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122), -(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76), -(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41), -(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43), -(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250), -(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130), -(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249), -(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156), -(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65), -(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18), -(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284), -(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283), -(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124), -(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272), -(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172), -(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228), -(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227), -(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82), -(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36), -(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37), -(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25), -(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34), -(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23), -(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63), -(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26), -(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24), -(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213), -(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226), -(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297), -(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280); +(193000000000000001,193000000000100001,'ABE2','ABE2',NULL,'PA','Breinigsville','18031','UTC-5','705 Boulder Drive',246), +(193000000000000002,193000000000100002,'ABE3','ABE3',NULL,'PA','Breinigsville','18031','UTC-5','650 Boulder Drive',245), +(193000000000000003,193000000000100003,'ABE4','ABE4','126','PA','Easton','18045','UTC-5','1610 Van Buren Road',142), +(193000000000000004,193000000000100004,'ABE5','ABE5',NULL,'PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',239), +(193000000000000005,193000000000100005,'ABE8','ABE8','39','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',133), +(193000000000000253,193000000000100253,'ABE8-309','ABE8-309',NULL,'NJ','Florence','08518-2802','UTC-5','309 Cedar Lane',259), +(193000000000000254,193000000000100254,'ABE8-401','ABE8-401',NULL,'NJ','Florence','08518','UTC-5','401 Independence Road',260), +(193000000000000006,193000000000100006,'ABQ2','ABQ2','76','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',107), +(193000000000000255,193000000000100255,'ABQ2-UPS','ABQ2-UPS',NULL,'AZ','GOODYEAR','85395','UTC-7','16601 W. SELLS DRIVE',104), +(193000000000000007,193000000000100007,'ACY2','ACY2','101','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',92), +(193000000000000008,193000000000100008,'AFW1','AFW1',NULL,'TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',149), +(193000000000000009,193000000000100009,'AFW5','AFW5','181','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',35), +(193000000000000010,193000000000100010,'AKR1','AKR1','153','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',156), +(193000000000000011,193000000000100011,'ALB1','ALB1','102','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',182), +(193000000000000012,193000000000100012,'AMA1','AMA1','132','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',137), +(193000000000000256,193000000000100256,'ATL1','ATL1','200','GA','Atlanta','30349','UTC-5','ATL1, 6055 S Fulton Pkwy, Atlanta, GA, 30349',2), +(193000000000000257,193000000000100257,'ATL1-WALMART','ATL1-WALMART',NULL,'GA','Atlanta','30349','UTC-5','6055 S Fulton Pkwy,',146), +(193000000000000013,193000000000100013,'ATL2N','ATL2N','157','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',39), +(193000000000000014,193000000000100014,'ATL3','ATL3',NULL,'GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',155), +(193000000000000015,193000000000100015,'ATL6','ATL6',NULL,'GA','East Point','30344','UTC-5','4200 North Commerce',210), +(193000000000000016,193000000000100016,'AVP1','AVP1','70','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',241), +(193000000000000017,193000000000100017,'AVP3','AVP3',NULL,'PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',240), +(193000000000000258,193000000000100258,'AZA4','AZA4','16','','','','','',72), +(193000000000000018,193000000000100018,'BDL1','BDL1',NULL,'CT','Windsor','06095','UTC-5','801 Day Hill Rd.',297), +(193000000000000019,193000000000100019,'BFI1','BFI1',NULL,'WA','Sumner','98390','UTC-8','1800 140th Avenue E.',216), +(193000000000000020,193000000000100020,'BFI3','BFI3','34','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',215), +(193000000000000021,193000000000100021,'BFI7','BFI7',NULL,'WA','Sumner','98390','UTC-8','1901 140th Ave E',214), +(193000000000000022,193000000000100022,'BJC1','BJC1','186','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',19), +(193000000000000023,193000000000100023,'BNA1','BNA1',NULL,'TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',229), +(193000000000000024,193000000000100024,'BNA2','BNA2','37','TN','Lebanon','37090','UTC-6','500 Duke DR',139), +(193000000000000025,193000000000100025,'BNA3','BNA3',NULL,'TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',148), +(193000000000000026,193000000000100026,'BNA4','BNA4',NULL,'TN','Nashville','37217','UTC-6','50 airways Blvd',228), +(193000000000000027,193000000000100027,'BNA6','BNA6','99','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',118), +(193000000000000028,193000000000100028,'BOS1','BOS1',NULL,'NH','Nashua','03063','UTC-5','10 State St.',251), +(193000000000000029,193000000000100029,'BOS2','BOS2',NULL,'MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',201), +(193000000000000030,193000000000100030,'BOS7','BOS7','103','MA','Fall River','02720','UTC-5','1180 Innovation Way',200), +(193000000000000031,193000000000100031,'BWI1','BWI1',NULL,'VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',250), +(193000000000000032,193000000000100032,'BWI2','BWI2',NULL,'MD','Baltimore','21224','UTC-5','2010 Broening Hwy',249), +(193000000000000033,193000000000100033,'BWI4','BWI4','150','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',110), +(193000000000000034,193000000000100034,'CAE1','CAE1',NULL,'SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',233), +(193000000000000259,193000000000100259,'CEG2','CEG2','79','','','','','',64), +(193000000000000035,193000000000100035,'CHA1','CHA1',NULL,'TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',231), +(193000000000000036,193000000000100036,'CHA2','CHA2','66','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',230), +(193000000000000037,193000000000100037,'CHO1','CHO1','120','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',83), +(193000000000000038,193000000000100038,'CLT2','CLT2','59','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',199), +(193000000000000039,193000000000100039,'CLT3','CLT3','104','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',122), +(193000000000000260,193000000000100260,'CMH1-11999','CMH1-11999',NULL,'OH','Etna','43062-7793','UTC-5','11999 National Road SW',247), +(193000000000000040,193000000000100040,'CMH2','CMH2','123','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',248), +(193000000000000041,193000000000100041,'CMH3','CMH3','32','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',189), +(193000000000000042,193000000000100042,'CVG1','CVG1',NULL,'KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',270), +(193000000000000043,193000000000100043,'CVG2','CVG2',NULL,'KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',269), +(193000000000000044,193000000000100044,'CVG3','CVG3',NULL,'KY','Hebron','41048','UTC-5','3680 Langley Dr.',268), +(193000000000000045,193000000000100045,'CVG5','CVG5',NULL,'KY','Hebron','41048','UTC-5','2285 Litton Lane',267), +(193000000000000046,193000000000100046,'DCA6','DCA6','135','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',143), +(193000000000000047,193000000000100047,'DEN2','DEN2','107','CO','Aurora','80019','UTC-7','22205 East 19th Ave',131), +(193000000000000048,193000000000100048,'DEN8','DEN8','73','CO','Aurora','80018','UTC-7','21000 E 13th Ave',128), +(193000000000000049,193000000000100049,'DET1','DET1','118','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',197), +(193000000000000050,193000000000100050,'DET2','DET2','136','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',190), +(193000000000000051,193000000000100051,'DFW5S','DFW5S',NULL,'TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',151), +(193000000000000052,193000000000100052,'DFW6','DFW6','105','TX','Coppell','75019','UTC-6','940 W Bethel Road',227), +(193000000000000053,193000000000100053,'DFW8','DFW8',NULL,'TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',225), +(193000000000000054,193000000000100054,'EWR4','EWR4',NULL,'NJ','Robbinsville','08691','UTC-5','50 New Canton Way',264), +(193000000000000055,193000000000100055,'EWR5','EWR5',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',263), +(193000000000000056,193000000000100056,'EWR7','EWR7',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',262), +(193000000000000057,193000000000100057,'EWR9','EWR9',NULL,'NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',261), +(193000000000000058,193000000000100058,'FAT2','FAT2','43','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',125), +(193000000000000261,193000000000100261,'FEDEX','FEDEX','12','','','92337','','10767',55), +(193000000000000059,193000000000100059,'FOE1','FOE1','117','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',184), +(193000000000000060,193000000000100060,'FTW1','FTW1','24','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',220), +(193000000000000061,193000000000100061,'FTW2','FTW2',NULL,'TX','Coppell','75019','UTC-6','2701 West Bethel Road',219), +(193000000000000062,193000000000100062,'FTW3','FTW3',NULL,'TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',97), +(193000000000000063,193000000000100063,'FTW5','FTW5','48','TX','Forney','75126','UTC-6','1475 AKRON WAY',129), +(193000000000000064,193000000000100064,'FWA4','FWA4','58','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',179), +(193000000000000065,193000000000100065,'GEG2','GEG2','108','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',159), +(193000000000000066,193000000000100066,'GEU2','GEU2','97','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',75), +(193000000000000067,193000000000100067,'GEU3','GEU3','81','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',86), +(193000000000000068,193000000000100068,'GEU5','GEU5','100','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',76), +(193000000000000069,193000000000100069,'GSO1','GSO1','131','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',172), +(193000000000000070,193000000000100070,'GSP1','GSP1','90','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',232), +(193000000000000071,193000000000100071,'GYR2','GYR2','7','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',196), +(193000000000000072,193000000000100072,'GYR3','GYR3','8','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',195), +(193000000000000262,193000000000100262,'HEA2','HEA2','77','','','18031','','650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States',65), +(193000000000000073,193000000000100073,'HGR6','HGR6','130','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',88), +(193000000000000074,193000000000100074,'HIA1','HIA1','148','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',95), +(193000000000000075,193000000000100075,'HLI2','HLI2','137','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',52), +(193000000000000076,193000000000100076,'HOU1','HOU1',NULL,'TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',224), +(193000000000000077,193000000000100077,'HOU2','HOU2',NULL,'TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',223), +(193000000000000078,193000000000100078,'HOU3','HOU3',NULL,'TX','Brookshire','77423','UTC-6','31555 Highway 90 E',222), +(193000000000000263,193000000000100263,'HOU7','HOU7','63','','','','','',67), +(193000000000000079,193000000000100079,'HOU8','HOU8','49','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',162), +(193000000000000080,193000000000100080,'HSV1','HSV1','80','AL','Madison','35756','UTC-6','7817 Greenbrier Road',150), +(193000000000000081,193000000000100081,'HSV2','HSV2',NULL,'AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',134), +(193000000000000082,193000000000100082,'IAH3','IAH3','57','TX','Houston','77032','UTC-6','15525 Milner Road',163), +(193000000000000264,193000000000100264,'IAK3','IAK3','44','','','','','',70), +(193000000000000083,193000000000100083,'IAZ1','IAZ1','179','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',11), +(193000000000000084,193000000000100084,'ICT2','ICT2','109','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',144), +(193000000000000085,193000000000100085,'IGA3','IGA3','197','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',10), +(193000000000000086,193000000000100086,'IGQ2','IGQ2','110','IL','University park','60484','UTC-6','23257 S. Central Avenue',160), +(193000000000000087,193000000000100087,'ILG1','ILG1','35','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',127), +(193000000000000265,193000000000100265,'IMI1','IMI1','199','MI','LANSING','48917-8539','UTC-5','6500 W Mt Hope Hwy 48917-8539 - LANSING, MI - United States',9), +(193000000000000088,193000000000100088,'IMO1','IMO1','178','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',18), +(193000000000000089,193000000000100089,'IMS1','IMS1','189','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',14), +(193000000000000090,193000000000100090,'INC1','INC1','190','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',13), +(193000000000000266,193000000000100266,'IND1-AMAZON','IND1-AMAZON',NULL,'IN','Whitestown','46075','UTC-5','4255 Anson Blvd',287), +(193000000000000267,193000000000100267,'IND1-WALMART','IND1-WALMART',NULL,'IN','Avon','46123','UTC-5','9590 AllPoints Pkwy',176), +(193000000000000091,193000000000100091,'IND2','IND2','127','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',96), +(193000000000000268,193000000000100268,'IND2-AMAZON','IND2-AMAZON',NULL,'IN','Plainfield','46168','UTC-5','715 Airtech Pkwy',286), +(193000000000000269,193000000000100269,'IND2-WALMART','IND2-WALMART',NULL,'IN','Avon','46123','UTC-5','9360 AllPoints Pkwy',177), +(193000000000000270,193000000000100270,'IND3','IND3','198','IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr',6), +(193000000000000271,193000000000100271,'IND3-亚马逊','IND3-亚马逊',NULL,'IN','Pkwy Plainfield','46168','','717 Airtech Pkwy Plainfield IN 46168,USA',147), +(193000000000000272,193000000000100272,'IND3-沃尔玛','IND3-沃尔玛',NULL,'IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr, Mccordsville, IN 46055',285), +(193000000000000092,193000000000100092,'IND4','IND4',NULL,'IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',284), +(193000000000000093,193000000000100093,'IND5','IND5','140','IN','Plainfield','46168','UTC-5','800 Perry Road',283), +(193000000000000094,193000000000100094,'IND6','IND6',NULL,'IN','Jeffersonville','47130','UTC-5','1453 E 10th St',282), +(193000000000000095,193000000000100095,'IND9','IND9','17','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',181), +(193000000000000273,193000000000100273,'IPA1','IPA1','194','PA','OLYPHANT','18447-2140','UTC-5','1300 Corporate Way',3), +(193000000000000096,193000000000100096,'ITX3','ITX3','187','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',17), +(193000000000000097,193000000000100097,'IUSF','IUSF','169','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',30), +(193000000000000098,193000000000100098,'IUSJ','IUSJ','98','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',77), +(193000000000000099,193000000000100099,'IUSP','IUSP','83','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',82), +(193000000000000274,193000000000100274,'IUSP-AWD','IUSP-AWD','83','CA','HESPERIA','92344','UTC-8','8140 Caliente Rd',91), +(193000000000000100,193000000000100100,'IUSQ','IUSQ','95','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',78), +(193000000000000101,193000000000100101,'IUSR','IUSR','158','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',48), +(193000000000000102,193000000000100102,'IUST','IUST','149','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',47), +(193000000000000103,193000000000100103,'IUSW','IUSW','156','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',41), +(193000000000000104,193000000000100104,'IUTE','IUTE','170','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',29), +(193000000000000105,193000000000100105,'IUTJ','IUTJ','177','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',16), +(193000000000000106,193000000000100106,'IUTL','IUTL','188','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',15), +(193000000000000107,193000000000100107,'IWA6','IWA6','191','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',12), +(193000000000000275,193000000000100275,'JAX3','JAX3','111','','','32210','','JAX3-32210',58), +(193000000000000276,193000000000100276,'JAX3-32210','JAX3-32210','111','FL','Jacksonville','32210-8686','UTC-5','13333 103rd Street',289), +(193000000000000277,193000000000100277,'JAX3-32221','JAX3-32221',NULL,'FL','Jacksonville','32221','UTC-5','13333 103rd Street',175), +(193000000000000108,193000000000100108,'JOT1','JOT1','93','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',80), +(193000000000000109,193000000000100109,'JVL1','JVL1','125','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',158), +(193000000000000110,193000000000100110,'KRB4','KRB4',NULL,'CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',186), +(193000000000000111,193000000000100111,'KY1','KY1','60','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',169), +(193000000000000112,193000000000100112,'LAN2','LAN2','96','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',108), +(193000000000000278,193000000000100278,'LAN2-48917','LAN2-48917',NULL,'MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',109), +(193000000000000113,193000000000100113,'LAS1','LAS1','5','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',255), +(193000000000000114,193000000000100114,'LAS2','LAS2',NULL,'NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',254), +(193000000000000115,193000000000100115,'LAS6','LAS6','50','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',140), +(193000000000000279,193000000000100279,'LAX1','LAX1','192','CA','Chino','91708','UTC-8','6750 Kimball Avenue, Chino,CA 91708,USA',8), +(193000000000000280,193000000000100280,'LAX1-WALMART','LAX1-WALMART',NULL,'CA','Chino','91708','UTC-8','6750 Kimball Avenue',185), +(193000000000000116,193000000000100116,'LAX2T','LAX2T','19','CA','CHINO','91708','UTC-8','6730 Kimball Ave',170), +(193000000000000281,193000000000100281,'LAX2T-WALMART','LAX2T-WALMART',NULL,'CA','Chino','91708','UTC-8','6720 Kimball Ave',188), +(193000000000000117,193000000000100117,'LAX9','LAX9','3','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',191), +(193000000000000118,193000000000100118,'LBE1','LBE1','112','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',94), +(193000000000000282,193000000000100282,'LBE1-UPS','LBE1-UPS',NULL,'PA','New Stanton','15672','UTC-5','165 GLENN FOX RD',93), +(193000000000000119,193000000000100119,'LEX1','LEX1',NULL,'KY','Lexington','40511','UTC-5','1850 Mercer RD',276), +(193000000000000120,193000000000100120,'LEX2','LEX2',NULL,'KY','Lexington','40511','UTC-5','172 Trade St.',275), +(193000000000000121,193000000000100121,'LFT1','LFT1','31','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',115), +(193000000000000122,193000000000100122,'LGB3','LGB3',NULL,'CA','Eastvale','91752','UTC-8','4950 Goodman Way',299), +(193000000000000123,193000000000100123,'LGB4','LGB4','45','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',300), +(193000000000000124,193000000000100124,'LGB6','LGB6','26','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',301), +(193000000000000125,193000000000100125,'LGB8','LGB8','2','CA','Rialto','92376','UTC-8','1568 N Linden Ave',302), +(193000000000000126,193000000000100126,'LIT2','LIT2','159','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',114), +(193000000000000127,193000000000100127,'MCC1','MCC1','161','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',40), +(193000000000000128,193000000000100128,'MCE1','MCE1','65','CA','TURLOCK','95380','UTC-8','FULKERTH RD',165), +(193000000000000129,193000000000100129,'MCI1','MCI1',NULL,'KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',279), +(193000000000000130,193000000000100130,'MCI4','MCI4','176','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',20), +(193000000000000283,193000000000100283,'MCO1-WALMART','MCO1-WALMART',NULL,'FL','Davenport','33897','UTC-5','5100 NORTH RIDGE TRAIL',116), +(193000000000000131,193000000000100131,'MCO2','MCO2','106','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',123), +(193000000000000132,193000000000100132,'MDT1','MDT1','121','PA','Carlisle','17015','UTC-5','2Ames Drive',238), +(193000000000000133,193000000000100133,'MDT4','MDT4','144','PA','York','17408','UTC-5','5125 Commerce Drive',136), +(193000000000000134,193000000000100134,'MDW2','MDW2','46','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',208), +(193000000000000284,193000000000100284,'MDW2-60433-3280','MDW2-60433-3280',NULL,'IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',119), +(193000000000000135,193000000000100135,'MDW6','MDW6','143','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',207), +(193000000000000136,193000000000100136,'MDW7','MDW7',NULL,'IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',206), +(193000000000000137,193000000000100137,'MDW8','MDW8',NULL,'IL','Waukegan','60085','UTC-6','1750 Bridge Drive',205), +(193000000000000138,193000000000100138,'MDW9','MDW9','36','IL','Aurora','60502','UTC-6','2865 Duke Parkway',204), +(193000000000000139,193000000000100139,'MEM1','MEM1','85','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',171), +(193000000000000285,193000000000100285,'MEM1S-WALMART','MEM1S-WALMART',NULL,'MS','OLIVE BRANCH','38654','UTC-6','9200 Alexander Road',183), +(193000000000000140,193000000000100140,'MEM6','MEM6','51','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',126), +(193000000000000141,193000000000100141,'MGE3','MGE3','133','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',209), +(193000000000000286,193000000000100286,'MIA1-33054','MIA1-33054',NULL,'FL','Opa-Locka','33054','UTC-5','14000 NW 37th Avenue',174), +(193000000000000287,193000000000100287,'MIA1-33182','MIA1-33182',NULL,'FL','Miami','33182','UTC-5','1900 NW 132nd Place Doral',290), +(193000000000000142,193000000000100142,'MIT2','MIT2','82','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',85), +(193000000000000143,193000000000100143,'MKC4','MKC4','67','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',278), +(193000000000000144,193000000000100144,'MKC6','MKC6',NULL,'KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',277), +(193000000000000145,193000000000100145,'MKE1','MKE1',NULL,'WI','Kenosha','53144','UTC-6','3501 120th Ave',211), +(193000000000000146,193000000000100146,'MQJ1','MQJ1','88','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',102), +(193000000000000288,193000000000100288,'MQY2','MQY2','193','TN','LEBANON','37090','UTC-6','MQY2 - 1301 Richard Petty Way 37090 - LEBANON, TN - United States',7), +(193000000000000147,193000000000100147,'MSP1','MSP1',NULL,'MN','Shakopee','55379','UTC-6','2601 4th Avenue East',198), +(193000000000000148,193000000000100148,'NJ3','NJ3','175','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',25), +(193000000000000289,193000000000100289,'NJ3-WALMART','NJ3-WALMART',NULL,'NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',87), +(193000000000000290,193000000000100290,'NJ3T','NJ3T','195','NJ','Pedricktown','08067','UTC-5','7 Sorbello Rd',5), +(193000000000000149,193000000000100149,'OAK3','OAK3','41','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',307), +(193000000000000150,193000000000100150,'OAK4','OAK4',NULL,'CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',308), +(193000000000000151,193000000000100151,'OKC2','OKC2','33','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',141), +(193000000000000152,193000000000100152,'OLM1','OLM1','172','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',27), +(193000000000000153,193000000000100153,'ONT2','ONT2',NULL,'CA','San Bernardino','92408','UTC-8','1910 E Central Ave',311), +(193000000000000154,193000000000100154,'ONT6','ONT6',NULL,'CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',310), +(193000000000000155,193000000000100155,'ONT8','ONT8','1','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',309), +(193000000000000156,193000000000100156,'ONT9','ONT9','25','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',306), +(193000000000000291,193000000000100291,'ORD1S-WALMART','ORD1S-WALMART',NULL,'IL','Joliet','60436','UTC-6','3501 Brandon Road',99), +(193000000000000157,193000000000100157,'ORD2','ORD2','21','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',111), +(193000000000000158,193000000000100158,'ORD6','ORD6',NULL,'IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',203), +(193000000000000159,193000000000100159,'ORF2','ORF2','38','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',121), +(193000000000000292,193000000000100292,'ORF2-FEDEX','ORF2-FEDEX',NULL,'VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',167), +(193000000000000293,193000000000100293,'ORF2-UPS','ORF2-UPS',NULL,'VA','Richmond','23297','UTC-5','9603 Coach Road',124), +(193000000000000160,193000000000100160,'PBI3','PBI3','113','FL','Jacksonville','32206','UTC-5','21 Jump Street',89), +(193000000000000161,193000000000100161,'PDX7','PDX7','68','OR','Salem','97317','UTC-8','4775 Depot Ct SE',135), +(193000000000000162,193000000000100162,'PHL1','PHL1',NULL,'DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',296), +(193000000000000163,193000000000100163,'PHL3','PHL3',NULL,'DE','New Castle','19720','UTC-5','1600 Johnson Way',295), +(193000000000000164,193000000000100164,'PHL4','PHL4','52','PA','Carlisle','17015','UTC-5','21 Roadway Dr',244), +(193000000000000165,193000000000100165,'PHL5','PHL5',NULL,'PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',243), +(193000000000000166,193000000000100166,'PHL5S','PHL5S','171','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',28), +(193000000000000294,193000000000100294,'PHL5S-WALMART','PHL5S-WALMART',NULL,'PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct',98), +(193000000000000167,193000000000100167,'PHL6','PHL6','69','PA','Carlisle','17015','UTC-5','675 Allen Rd.',242), +(193000000000000168,193000000000100168,'PHL7','PHL7',NULL,'DE','Middletown','19709','UTC-5','560 Merrimac Ave',294), +(193000000000000169,193000000000100169,'PHL8','PHL8',NULL,'DE','Middletown','19709','UTC-5','727 N Broad St',293), +(193000000000000170,193000000000100170,'PHX3','PHX3',NULL,'AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',315), +(193000000000000171,193000000000100171,'PHX5','PHX5','72','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',314), +(193000000000000172,193000000000100172,'PHX6','PHX6',NULL,'AZ','Phoenix','85043','UTC-7','4750 West Mohave St',313), +(193000000000000173,193000000000100173,'PHX7','PHX7','74','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',312), +(193000000000000174,193000000000100174,'PIT1','PIT1',NULL,'PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',237), +(193000000000000175,193000000000100175,'PIT2','PIT2','78','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',166), +(193000000000000176,193000000000100176,'POC1','POC1','128','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',54), +(193000000000000177,193000000000100177,'POC2','POC2','151','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',46), +(193000000000000178,193000000000100178,'POC3','POC3','129','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',53), +(193000000000000179,193000000000100179,'PPO4','PPO4','122','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',57), +(193000000000000180,193000000000100180,'PSC2','PSC2','75','WA','PASCO','99301','UTC-8','1351 S Road 40 E',101), +(193000000000000295,193000000000100295,'PSC2-UPS','PSC2-UPS',NULL,'OR','Portland','97242','UTC-8','9930 NE Alderwood Rd',100), +(193000000000000181,193000000000100181,'PSP3','PSP3','162','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',36), +(193000000000000182,193000000000100182,'QXY5','QXY5','92','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',79), +(193000000000000183,193000000000100183,'QXY8','QXY8','154','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',43), +(193000000000000184,193000000000100184,'QZZ7','QZZ7','184','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',22), +(193000000000000185,193000000000100185,'RDU2','RDU2','56','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',154), +(193000000000000186,193000000000100186,'RDU4','RDU4','145','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',103), +(193000000000000187,193000000000100187,'RFD2','RFD2','71','IL','Huntley','60142','UTC-6','11500 Freeman Road',153), +(193000000000000188,193000000000100188,'RFD4','RFD4','174','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',24), +(193000000000000189,193000000000100189,'RIC1','RIC1','142','VA','Petersburg','23803','UTC-5','5000 Commerce Way',218), +(193000000000000190,193000000000100190,'RIC2','RIC2',NULL,'VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',217), +(193000000000000191,193000000000100191,'RMN3','RMN3','84','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',152), +(193000000000000192,193000000000100192,'RNO1','RNO1',NULL,'NV','Fernley','89408','UTC-8','1600 East Newlands RD',256), +(193000000000000193,193000000000100193,'RNO2','RNO2',NULL,'NV','Reno','85906','UTC-8','8000 North Virginia Street',253), +(193000000000000194,193000000000100194,'RNO4','RNO4',NULL,'NV','Reno','89506','UTC-8','8000 North Virginia Street',252), +(193000000000000195,193000000000100195,'RYY2','RYY2','94','GA','White','30184','UTC-5','1250 Cassville White Road,',81), +(193000000000000196,193000000000100196,'SAT1','SAT1','23','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',226), +(193000000000000197,193000000000100197,'SAT4','SAT4','61','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',157), +(193000000000000198,193000000000100198,'SAT6','SAT6','183','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',23), +(193000000000000199,193000000000100199,'SAV3','SAV3','124','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',106), +(193000000000000200,193000000000100200,'SBD1','SBD1','4','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',193), +(193000000000000201,193000000000100201,'SBD2','SBD2','15','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',187), +(193000000000000202,193000000000100202,'SCK1','SCK1','20','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',105), +(193000000000000203,193000000000100203,'SCK3','SCK3','54','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',68), +(193000000000000204,193000000000100204,'SCK4','SCK4','10','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',194), +(193000000000000205,193000000000100205,'SCK8','SCK8','141','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',112), +(193000000000000206,193000000000100206,'SDF1','SDF1',NULL,'KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',274), +(193000000000000207,193000000000100207,'SDF2','SDF2',NULL,'KY','Watterson Park','40218','UTC-5','4360 Robards Ln',273), +(193000000000000208,193000000000100208,'SDF4','SDF4',NULL,'KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',272), +(193000000000000209,193000000000100209,'SDF6','SDF6',NULL,'KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',271), +(193000000000000210,193000000000100210,'SDF7','SDF7',NULL,'KY','Shepherdsville','40165','UTC-5','300 Omicron Court',266), +(193000000000000211,193000000000100211,'SDF8','SDF8',NULL,'IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',281), +(193000000000000212,193000000000100212,'SDF9','SDF9',NULL,'KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',265), +(193000000000000213,193000000000100213,'SEA6','SEA6',NULL,'WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',213), +(193000000000000214,193000000000100214,'SEA8','SEA8',NULL,'WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',212), +(193000000000000215,193000000000100215,'SJC7','SJC7','30','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',303), +(193000000000000296,193000000000100296,'SLC1N','SLC1N','196','UT','Salt Lake City','84116','UTC-7','990 N 6550 W',4), +(193000000000000216,193000000000100216,'SLC2','SLC2','86','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',161), +(193000000000000217,193000000000100217,'SLC3','SLC3','185','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',21), +(193000000000000297,193000000000100297,'SMF1-AMAZON','SMF1-AMAZON',NULL,'CA','Sacramento','95835-9505','UTC-8','4900 W Elkhorn Blvd',298), +(193000000000000298,193000000000100298,'SMF1-WALMART','SMF1-WALMART',NULL,'CA','Sacramento','95837','UTC-8','7000 Powerline Rd',173), +(193000000000000218,193000000000100218,'SMF3','SMF3','9','CA','Stockton','95206-8202','UTC-8','3923 S B ST',192), +(193000000000000219,193000000000100219,'SMF6','SMF6','28','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',113), +(193000000000000299,193000000000100299,'SMF7','SMF7','53','','','','','',69), +(193000000000000220,193000000000100220,'SNA4','SNA4','18','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',304), +(193000000000000221,193000000000100221,'STL3','STL3','152','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',117), +(193000000000000222,193000000000100222,'STL4','STL4','115','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',202), +(193000000000000300,193000000000100300,'STL6','STL6','64','','','','','',66), +(193000000000000223,193000000000100223,'SWF1','SWF1','134','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',130), +(193000000000000224,193000000000100224,'SWF2','SWF2','62','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',84), +(193000000000000301,193000000000100301,'SWF2-10710','SWF2-10710',NULL,'NY','Yonkers','10710','UTC-5','557 Tuckahoe Road',145), +(193000000000000302,193000000000100302,'SWF2-12533','SWF2-12533',NULL,'NY','Hopewell Junction','12533-6159','UTC-5','76 Patriot Way',168), +(193000000000000225,193000000000100225,'TCY1','TCY1','139','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',49), +(193000000000000226,193000000000100226,'TCY2','TCY2','138','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',51), +(193000000000000227,193000000000100227,'TEB3','TEB3','47','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',258), +(193000000000000228,193000000000100228,'TEB4','TEB4','55','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',138), +(193000000000000229,193000000000100229,'TEB6','TEB6','114','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',257), +(193000000000000230,193000000000100230,'TEB9','TEB9','40','NJ','Somerset','08873','UTC-5','601 Randolph Road',164), +(193000000000000231,193000000000100231,'TMB8','TMB8','116','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',73), +(193000000000000303,193000000000100303,'TMB8-UPS','TMB8-UPS',NULL,'FL','Miami Gardens','33055','UTC-5','8675309 Jenny Lane',74), +(193000000000000232,193000000000100232,'TOL3','TOL3','173','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',26), +(193000000000000233,193000000000100233,'TPA1','TPA1',NULL,'FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',292), +(193000000000000234,193000000000100234,'TPA2','TPA2','22','FL','Lakeland','33811','UTC-5','1760 County Line Rd',291), +(193000000000000235,193000000000100235,'TPA3','TPA3','146','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',132), +(193000000000000236,193000000000100236,'TUL1','TUL1',NULL,'KS','Coffeyville','67337','UTC-6','2654 North Highway 169',280), +(193000000000000304,193000000000100304,'UPS','UPS','11','','','92337','','10767',56), +(193000000000000237,193000000000100237,'VGT2','VGT2','6','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',180), +(193000000000000305,193000000000100305,'VGT2-FEDEX','VGT2-FEDEX',NULL,'NV','LAS VEGAS','89115-201','UTC-8','6401 Howdy Wells Ave',120), +(193000000000000306,193000000000100306,'VGT2-UPS','VGT2-UPS',NULL,'CA','March Air Reserve Base','92518','UTC-8','20701 Krameria Ave',178), +(193000000000000238,193000000000100238,'VUBA','VUBA',NULL,'PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',236), +(193000000000000239,193000000000100239,'VUGA','VUGA',NULL,'PA','Pittston','18643','UTC-5','508 Delaware Avenue West',235), +(193000000000000240,193000000000100240,'WBW2','WBW2','119','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',90), +(193000000000000241,193000000000100241,'XAV3','XAV3','180','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',44), +(193000000000000307,193000000000100307,'XBN3','XBN3','91','','','','','',61), +(193000000000000308,193000000000100308,'XCH2','XCH2','87','','','','','',63), +(193000000000000242,193000000000100242,'XEW5','XEW5','163','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',45), +(193000000000000243,193000000000100243,'XIN5','XIN5','182','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',33), +(193000000000000244,193000000000100244,'XLX1','XLX1','160','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',42), +(193000000000000245,193000000000100245,'XLX6','XLX6','166','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',31), +(193000000000000246,193000000000100246,'XLX7','XLX7','27','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',71), +(193000000000000309,193000000000100309,'XMI3','XMI3','89','','','','','',62), +(193000000000000247,193000000000100247,'XPB2','XPB2','165','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',34), +(193000000000000248,193000000000100248,'XRI3','XRI3','167','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',32), +(193000000000000249,193000000000100249,'XUSB','XUSB',NULL,'TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',221), +(193000000000000250,193000000000100250,'XUSC','XUSC',NULL,'PA','Carlisle','17013','UTC-5','40 Logistics Drive',234), +(193000000000000251,193000000000100251,'XUSD','XUSD',NULL,'CA','Stockton','95206','UTC-8','1909 Zephyr St',305), +(193000000000000252,193000000000100252,'XUSE','XUSE',NULL,'IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',288), +(193000000000000310,193000000000100310,'洛杉矶仓一件代发','洛杉矶仓一件代发','CADF','CA','RANCHO CUCAMONGA','91730','UTC-8','10767 7TH STREET',60), +(193000000000000311,193000000000100311,'海外仓打托','海外仓打托','13','','','91730','','91730',50), +(193000000000000312,193000000000100312,'私人仓','私人仓','14','','','私人仓','','私人仓',59), +(193000000000000313,193000000000100313,'纽约仓','纽约仓',NULL,'NJ','New Jersey','08817','UTC-5','13、14号门75 Carter Dr Edison,',38), +(193000000000000314,193000000000100314,'纽约仓一件代发','纽约仓一件代发','NJDF','NJ','New Jersey','08817','UTC-5','Door13-14,75 Carter Dr Edison,',37); DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; DELIMITER // CREATE PROCEDURE assert_wh_fba_public_master_data_seed() BEGIN - IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252'; + IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 314 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是314'; END IF; IF EXISTS ( SELECT 1 FROM tmp_wh_fba_public_master_data - WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) - OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$' - OR TRIM(city) = '' - OR TRIM(state_province) = '' - OR TRIM(address_line1) = '' - OR TRIM(postal_code) = '' - OR TRIM(timezone) = '' + WHERE TRIM(warehouse_code) = '' + OR warehouse_code <> UPPER(TRIM(warehouse_code)) + OR (internal_code IS NOT NULL AND internal_code NOT REGEXP '^[A-Z0-9]{1,16}$') ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在空编码或内部编号格式异常'; END IF; IF EXISTS ( @@ -7101,7 +7161,7 @@ CALL assert_wh_fba_public_master_data_seed(); DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; INSERT INTO wh_warehouse ( - id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version ) @@ -7109,6 +7169,7 @@ SELECT source.warehouse_id_seed, 0, source.warehouse_code, + source.internal_code, source.warehouse_name, 'FBA', 'US', @@ -7126,6 +7187,7 @@ SELECT 0 FROM tmp_wh_fba_public_master_data source ON DUPLICATE KEY UPDATE + internal_code = VALUES(internal_code), warehouse_type = VALUES(warehouse_type), country_code = VALUES(country_code), timezone = VALUES(timezone), @@ -7181,7 +7243,7 @@ BEGIN WHERE warehouse.tenant_id = 0 AND warehouse.warehouse_type = 'FBA' AND warehouse.country_code = 'US' - AND warehouse.deleted = 0) <> 252 THEN + AND warehouse.deleted = 0) <> 314 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败'; END IF; @@ -7199,6 +7261,7 @@ BEGIN AND address.deleted = 0 WHERE warehouse.id IS NULL OR address.id IS NULL + OR NOT (warehouse.internal_code <=> source.internal_code) OR address.country_code <> 'US' OR address.state_province <> source.state_province OR address.city <> source.city diff --git a/firstleg/docs/terminal-drayage-mail.md b/firstleg/docs/terminal-drayage-mail.md index 59a202dcc..fa4e2a968 100644 --- a/firstleg/docs/terminal-drayage-mail.md +++ b/firstleg/docs/terminal-drayage-mail.md @@ -3,8 +3,8 @@ ## 行为 - 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。 -- 更换为另一车队时,与车队变更在同一数据库事务中生成 `CANCEL_PICKUP`、`REQUEST_PICKUP` 两个任务。取消邮件使用原请求的收件地址快照;没有原请求时读取原车队当前资料,允许原车队已停用。新车队必须为 ACTIVE/TRUCKING。 -- 同一车队重复选择不发邮件、不改变版本。发送操作递增拖车版本,同版本的重复点击不会再次入队。 +- 更换为另一车队时,以该柜、原车队是否存在 `REQUEST_PICKUP` 邮件记录作为“是否已下发”的事实。已下发过时,与车队变更在同一数据库事务中依次生成原车队 `CANCEL_PICKUP`、新车队 `REQUEST_PICKUP` 两个任务,取消邮件使用原请求的收件地址快照;从未下发过时只生成新车队 `REQUEST_PICKUP`,不向原车队发送取消邮件。新车队必须为 ACTIVE/TRUCKING。 +- 同一车队重复选择不发邮件、不改变版本。列表“发送邮件”允许人工重复点击,每次使用最新版本都会新增一封提柜请求,便于测试或再次通知;同一个幂等键的请求不会重复入队。 - 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。 - `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。 - 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。 @@ -26,8 +26,8 @@ Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮 | `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_USERNAME` | SMTP 登录账号,同时作为邮件发件地址;QQ 邮箱填写完整邮箱地址 | +| `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 授权码,仅通过环境注入,不使用 QQ 登录密码 | | `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 | | `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 | 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 d44d0ed46..026a5fbc9 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 @@ -20,11 +20,10 @@ public class TerminalDrayageMailDispatcher { @Value("${baiyue.terminal-drayage.mail.port:587}") private int port; @Value("${baiyue.terminal-drayage.mail.username}") private String username; @Value("${baiyue.terminal-drayage.mail.password}") private String password; - @Value("${baiyue.terminal-drayage.mail.from}") private String from; @Value("${baiyue.terminal-drayage.mail.ssl:false}") private boolean ssl; JavaMailSenderImpl sender() { - TerminalDrayageMailService.validateAddress(from); + TerminalDrayageMailService.validateAddress(username); var sender = new JavaMailSenderImpl(); sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password); sender.setDefaultEncoding("UTF-8"); @@ -47,7 +46,7 @@ public class TerminalDrayageMailDispatcher { if (mapper.claimMail(mail.id()) != 1) continue; try { var message = new SimpleMailMessage(); - message.setFrom(from); message.setTo(mail.recipient()); + message.setFrom(username); message.setTo(mail.recipient()); message.setSubject(mail.subject()); message.setText(mail.body()); sender().send(message); if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id()); 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 afd2ede88..e4044bc83 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 @@ -34,11 +34,12 @@ public class TerminalDrayageMailService { 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); + if (previous != null) { + enqueue(containerId, containerNo, oldSupplierId, previous.fleetName(), previous.recipient(), + "CANCEL_PICKUP", operatorId); + } enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId); } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index aa8f58cd5..6033ef4fa 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -37,7 +37,6 @@ baiyue: 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: diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index e13196f78..97d86e172 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -55,7 +55,7 @@ paths: /api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email: post: summary: 将当前车队提柜邮件加入发送队列 - description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。首次更换车队自动原子入队取消和新提柜两封邮件。同一车队重复选择不发信。 + 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}}}}}} 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 index 50c12bebc..a58ffa97f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java @@ -7,6 +7,7 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; @@ -16,7 +17,7 @@ class TerminalDrayageMailDispatcherTest { TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper)); void setup() { - ReflectionTestUtils.setField(worker,"from","operations@example.com"); + ReflectionTestUtils.setField(worker,"username","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))); @@ -27,8 +28,10 @@ class TerminalDrayageMailDispatcherTest { @Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() { setup(); worker.dispatch(); var ordered = inOrder(mapper,sender); + var sent = org.mockito.ArgumentCaptor.forClass(SimpleMailMessage.class); ordered.verify(mapper).claimMail(1L); - ordered.verify(sender).send(any(SimpleMailMessage.class)); + ordered.verify(sender).send(sent.capture()); + assertThat(sent.getValue().getFrom()).isEqualTo("operations@example.com"); ordered.verify(mapper).completeMail(1L); when(mapper.claimMail(1L)).thenReturn(0); worker.dispatch(); 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 index e8863fdc7..156204c38 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java @@ -27,6 +27,8 @@ class TerminalDrayageMailServiceTest { } @Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() { + when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask( + 90L,11L,1L,"Old Fleet","old@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null)); 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"), @@ -35,6 +37,16 @@ class TerminalDrayageMailServiceTest { eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L)); } + @Test void replacementWithoutPreviousPickupMailQueuesOnlyNewRequest() { + service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L); + + verify(mapper, never()).insertMail(anyLong(), eq(11L), eq(1L), anyString(), anyString(), + eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L)); + 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)); + verify(suppliers, never()).truckingContact("test-token", 1L, false); + } + @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)); 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 babd5ddca..0520234e4 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 @@ -374,18 +374,23 @@ class TerminalDrayageServiceTest { } @Test - void manualPickupEmailUsesLockedVersionAndRejectsStaleClick() { + void manualPickupEmailRejectsStaleClickAndAllowsRepeatWithLatestVersion() { 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;}); + when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> { + row.setVersion(invocation.getArgument(1, Integer.class) + 1); + 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); + service.sendPickupEmail(11L,7L,"admin","mail-3", + new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(4)); + verify(mails,times(2)).request(11L,"MSCU1234567",91L,7L); } private Context context(String node, LocalDateTime unloadedAt) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index b2afab27b..a8c826b1d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -55,6 +56,7 @@ class InboundPlanServiceTest { @Mock DropshipOrderService dropshipOrders; @Mock WarehouseResourceGateway warehouseGateway; @Mock LastlegOrderCommandRecordMapper commandMapper; + @Mock LastlegSourcePalletMapper sourcePalletMapper; private InboundPlanService service; @@ -70,6 +72,7 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "commandMapper", commandMapper); + ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper); ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 74e48d6cc..ab28ff460 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -17,6 +17,10 @@ test('finance fixture module is independently exported and marker isolated', () test('fixture covers weight, volume, manual order, and container allocation contracts', () => { for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis)) + assert.match(source, /chargeMode: pricingProfile\.chargeUnit/) + assert.match(source, /customer-rules\/versions\?tenantId=/) + assert.match(source, /taxMode: 'TAX_EXCLUDED'/) + assert.match(source, /inbound\?\.summary\?\.businessType \|\| profile\.businessType/) assert.match(source, /direction === 'PAYABLE' \? \{ settlementMode: 'ON_ACCOUNT' \}/) assert.match(source, /supplierId: id\(supplier\.id\), settlementMode: 'ON_ACCOUNT'/) assert.match(source, /request\(context, 'POST', '\/api\/platform\/finance\/v1\/cost-entries'/) @@ -123,6 +127,7 @@ function hookSet({ missing = false } = {}) { let automaticIndex = 0 return { loadPricingEvidence: async () => ({ activeVersionIds: ['PRICE-1'] }), + ensureCustomerChargeRule: async () => ({ id: 'RULE-CBM', chargeMode: 'CBM' }), createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701', orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }), measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }), @@ -153,7 +158,7 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup await run(context) const fixture = context.createdResources.financeFullFixtures assert.equal(fixture.customerId, '701') - assert.equal(fixture.orders.length, 5) + assert.equal(fixture.orders.length, 6) assert.equal(fixture.orders[0].orderNo, 'NO-AUTO1') assert.equal(fixture.orders[0].waybillId, 'WB-1') assert.equal(fixture.orders[0].actualWeightKg, 12.5) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index f5687b494..4d7b4a27d 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -55,8 +55,18 @@ async function createOrder(context, suffix, pricingProfile = null) { // The orders module discovers candidates independently. Reorder only those // real responses so its payload uses one exact active price-book row. globalThis.fetch = async (input, init = {}) => { - const response = await originalFetch(input, init) const url = String(input) + let effectiveInit = init + if ((init.method || 'GET') === 'POST' + && url.includes('/api/merchant/v1/first-leg/pricing/estimate') + && typeof init.body === 'string') { + const body = JSON.parse(init.body) + effectiveInit = { + ...init, + body: JSON.stringify({ ...body, chargeMode: pricingProfile.chargeUnit }) + } + } + const response = await originalFetch(input, effectiveInit) if ((init.method || 'GET') === 'GET' && (url.includes('/api/merchant/v1/warehouses?') || url.includes('/api/merchant/v1/first-leg/pricing/channels?'))) { const body = await response.clone().json().catch(() => null) @@ -99,7 +109,7 @@ async function measureOrder(context, resource) { if (context.financeFixtureHooks?.measureOrder) return context.financeFixtureHooks.measureOrder(context, resource) const originalMarker = context.marker const createdKeys = ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound'] - const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet', 'waitSplitVariant'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }])) context.marker = resource.marker @@ -114,6 +124,13 @@ async function measureOrder(context, resource) { // Reuse that warehouse's real pallet master instead of requiring the bulk-data // generator's two fixed Yiwu pallet codes. context.context.allowAnyExistingPallet = true + context.context.waitSplitVariant = { + ...(context.context.waitSplitVariant || {}), + // The production pricing service chooses the dominant real billing basis. + // Keep the CBM sample's measured gross weight below its measured volume + // weight so the fixture proves the actual VOLUME_CBM path. + grossWeightPerPalletKg: resource.pricingProfile?.chargeUnit === 'CBM' ? 20 : 100, + } try { const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) await measurement.run(context) @@ -299,7 +316,8 @@ async function loadPricingEvidence(context) { if (matches.length !== 1) continue profiles.push({ versionId: id(version.id), rateId: id(rate.id), chargeUnit: rate.chargeUnit, originWarehouseId: id(rate.originWarehouseId), channelCode: rate.channelCode, - pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode }) + pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode, + businessType: rate.businessType || 'FBA' }) } } context.assert(MODULE, 'ACTIVE 价格簿包含 KG 与 CBM 可用真实组合', response, @@ -308,6 +326,57 @@ async function loadPricingEvidence(context) { return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles } } +async function ensureCustomerChargeRule(context, resource, profile) { + if (context.financeFixtureHooks?.ensureCustomerChargeRule) { + return context.financeFixtureHooks.ensureCustomerChargeRule(context, resource, profile) + } + if (!resource?.tenantId || profile?.pricingTaxMode !== 'TAX_EXCLUDED') return null + const tenantId = id(resource.tenantId) + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(tenantId)}`) + const inbound = context.apiSuccess(MODULE, '读取不包税入库计费业务类型', inboundResponse, + `inboundId=${resource.inboundId}`, OPTIONS) + const businessType = inbound?.summary?.businessType || profile.businessType + if (!businessType) { + blocker(context, '取得不包税入库计费业务类型', + `inboundId=${resource.inboundId} 未返回 businessType,不能创建猜测的客户计费规则`) + return null + } + const listPath = `/api/platform/v1/fba-pricing/customer-rules?tenantId=${encodeURIComponent(tenantId)}&customerId=${encodeURIComponent(tenantId)}` + const listResponse = await context.request('GET', listPath) + const listed = context.apiSuccess(MODULE, '查询不包税客户计费规则', listResponse, + `customerId=${tenantId}, businessType=${businessType}`, OPTIONS) + const applicable = rows(listed).filter((rule) => rule?.status === 'ACTIVE' + && rule?.businessType === businessType && rule?.taxMode === 'TAX_EXCLUDED') + let desired = applicable.find((rule) => rule?.chargeMode === profile.chargeUnit) + if (!desired) { + const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19) + const body = { + ruleCode: `FIN_P1_${profile.chargeUnit}_${tenantId}`.slice(0, 50), customerId: tenantId, + customerName: `财务P1验收客户-${tenantId}`.slice(0, 128), businessType, + chargeMode: profile.chargeUnit, taxMode: 'TAX_EXCLUDED', conversionFactor: 1, + roundingScale: 3, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom, + effectiveTo: null, remark: `${context.marker}-VOLUME_CBM` + } + const createResponse = await request(context, 'POST', + `/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=${encodeURIComponent(tenantId)}`, body) + desired = context.apiSuccess(MODULE, '创建不包税客户计费规则', createResponse, + `customerId=${tenantId}, chargeMode=${profile.chargeUnit}`, OPTIONS) + } + const needsDefault = applicable.length > 1 + || (applicable.length === 1 && id(applicable[0]?.id) !== id(desired?.id)) + if (desired?.id && needsDefault && desired.defaultRule !== true) { + const defaultResponse = await request(context, 'PUT', + `/api/platform/v1/fba-pricing/customer-rules/${encodeURIComponent(desired.id)}/default?tenantId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, '设置不包税客户唯一默认计费规则', defaultResponse, + `ruleId=${desired.id}, chargeMode=${profile.chargeUnit}`, OPTIONS) + } + context.assert(MODULE, '不包税自动计费具备客户计费方式', listResponse, + Boolean(desired?.id) && desired?.chargeMode === profile.chargeUnit, + `customerId=${tenantId}, chargeMode=${desired?.chargeMode || 'missing'}, ruleId=${id(desired?.id) || 'missing'}`, OPTIONS) + return desired || null +} + async function createMarkerContainer(context, resource) { if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource) if (!resource) { @@ -387,12 +456,15 @@ export async function run(context) { const pricingEvidence = await loadPricingEvidence(context) const resources = [] const profiles = pricingEvidence.profiles || [] - for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL'].entries()) { + for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL', 'CONTAINER'].entries()) { const resource = await createOrder(context, suffix, profiles[index % profiles.length] || null) context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource), resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS) if (resource) resources.push(resource) } + for (const resource of resources.filter((item) => item.pricingProfile?.pricingTaxMode === 'TAX_EXCLUDED')) { + await ensureCustomerChargeRule(context, resource, resource.pricingProfile) + } const measured = [] for (const resource of resources) { const result = await measureOrder(context, resource) @@ -430,7 +502,7 @@ export async function run(context) { if (fee) fixtures.push(fee) } } - const manualResources = [measured.at(-1)].filter(Boolean) + const manualResources = [measured[4]].filter(Boolean) if (feeItems.PAYABLE && supplier?.id) { for (const [index, manualResource] of manualResources.entries()) { const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10, @@ -440,7 +512,11 @@ export async function run(context) { } } - const container = (measured[0] ? await createMarkerContainer(context, measured[0]) : null) + // A source whose fee has already been sent is intentionally frozen against + // loading-plan membership changes. Use a dedicated measured order for the + // container fixture so finance setup never conflicts with that invariant. + const containerResource = measured[5] + const container = (containerResource ? await createMarkerContainer(context, containerResource) : null) || context.createdResources?.phase3ContainerLoading const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`)) && container?.loadingPlanId && container?.containerBookingId diff --git a/warehouse/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs index 6dbb91c9e..4693fa5d4 100644 --- a/warehouse/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs @@ -9,6 +9,7 @@ const sqlPath = resolve(warehouseRoot, 'src/main/resources/db/warehouse-public-m const acceptedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.accepted.csv') const rejectedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.rejected.csv') const fixedWarehousePath = resolve(warehouseRoot, 'src/main/resources/db/master-data/platform-fixed-warehouses.csv') +const unifiedSqlPath = resolve(warehouseRoot, '..', 'deploy/sql/头程数据库统一增量-MySQL8.sql') function parseCsv(text) { const rows = [] @@ -49,25 +50,22 @@ function parseCsv(text) { } const sql = readFileSync(sqlPath, 'utf8') +const unifiedSql = readFileSync(unifiedSqlPath, 'utf8') const accepted = parseCsv(readFileSync(acceptedPath, 'utf8')) const rejected = parseCsv(readFileSync(rejectedPath, 'utf8')) const fixedWarehouses = parseCsv(readFileSync(fixedWarehousePath, 'utf8')) -assert.equal(accepted.length, 252) -assert.equal(rejected.length, 54) +assert.equal(accepted.length, 314) +assert.equal(rejected.length, 0) assert.equal(fixedWarehouses.length, 4) const codes = new Set() const warehouseIds = new Set() const addressIds = new Set() for (const row of accepted) { - assert.match(row.warehouse_code, /^[A-Z0-9]{3,5}$/) + assert.ok(row.warehouse_code.trim()) assert.equal(row.warehouse_code, row.warehouse_code.trim().toUpperCase()) - assert.ok(row.city.trim()) - assert.ok(row.state_province.trim()) - assert.ok(row.address_line1.trim()) - assert.ok(row.postal_code.trim()) - assert.ok(row.timezone.trim()) + if (row.source_internal_id) assert.match(row.source_internal_id, /^[A-Z0-9]{1,16}$/) assert.equal(row.country_code, 'US') assert.ok(!codes.has(row.warehouse_code), `duplicate code ${row.warehouse_code}`) assert.ok(!warehouseIds.has(row.warehouse_id_seed), `duplicate warehouse id ${row.warehouse_id_seed}`) @@ -75,10 +73,15 @@ for (const row of accepted) { codes.add(row.warehouse_code) warehouseIds.add(row.warehouse_id_seed) addressIds.add(row.address_id_seed) + const internalCode = row.source_internal_id ? `'${row.source_internal_id}'` : 'NULL' + const tuplePrefix = `(${row.warehouse_id_seed},${row.address_id_seed},'${row.warehouse_code}','${row.warehouse_name}',${internalCode},` + assert.ok(sql.includes(tuplePrefix), `standalone SQL missing ${row.warehouse_code}`) + assert.ok(unifiedSql.includes(tuplePrefix), `unified SQL missing ${row.warehouse_code}`) } -assert.ok(rejected.some((row) => row.reject_reason.includes('non_fba_business_node'))) -assert.equal(rejected.filter((row) => row.reject_reason.includes('timezone_pending_confirmation')).length, 1) +assert.ok(accepted.some((row) => row.warehouse_code === 'FEDEX' && row.city === '')) +assert.ok(accepted.some((row) => row.warehouse_code === '纽约仓一件代发' && row.source_internal_id === 'NJDF')) +assert.equal(accepted.filter((row) => row.source_internal_id === '83').length, 2) assert.deepEqual(fixedWarehouses.map((row) => row.warehouse_code), [ 'CN-YIWU-01', @@ -105,7 +108,9 @@ assert.match(sql, /FROM tmp_wh_fixed_platform_warehouse source[\s\S]+ON DUPLICAT assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data/) assert.match(sql, /JOIN wh_warehouse warehouse\s+ON warehouse\.tenant_id = 0\s+AND warehouse\.warehouse_code = source\.warehouse_code/s) -assert.match(sql, /ON DUPLICATE KEY UPDATE\s+warehouse_type = VALUES\(warehouse_type\),\s+country_code = VALUES\(country_code\),\s+timezone = VALUES\(timezone\),\s+updated_at = CURRENT_TIMESTAMP\(3\);/s) +assert.match(sql, /ON DUPLICATE KEY UPDATE\s+internal_code = VALUES\(internal_code\),\s+warehouse_type = VALUES\(warehouse_type\),\s+country_code = VALUES\(country_code\),\s+timezone = VALUES\(timezone\),\s+updated_at = CURRENT_TIMESTAMP\(3\);/s) +assert.match(sql, /internal_code VARCHAR\(16\) NULL/) +assert.match(sql, /source\.internal_code/) assert.doesNotMatch(sql, /status\s*=\s*VALUES\(status\)/) assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 796b1020c..a519c92e3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -27,6 +27,8 @@ public final class WarehouseRequests { Long tenantId, @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 50) String warehouseCode, + @Schema(description = "FBA internal code; only available for FBA warehouses / FBA 内部编号,仅 FBA 仓可填写", example = "154", maxLength = 16) + @Pattern(regexp = "[A-Za-z0-9]{1,16}") String internalCode, @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 120) String warehouseName, @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}, defaultValue = "DOMESTIC") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java index 9c300aed9..972da8a64 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java @@ -12,6 +12,7 @@ public record WarehouseSummaryResponse( @Schema(description = "Warehouse ID / 仓库 ID") Long id, @Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId, @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "FBA internal code / FBA 内部编号") String internalCode, @Schema(description = "Warehouse name / 仓库名称") String warehouseName, @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) String warehouseType, @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode, @@ -27,7 +28,7 @@ public record WarehouseSummaryResponse( ) { public static WarehouseSummaryResponse of(WarehouseEntity warehouse, WarehouseAddressEntity address) { return new WarehouseSummaryResponse( - warehouse.getId(), warehouse.getTenantId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName(), + warehouse.getId(), warehouse.getTenantId(), warehouse.getWarehouseCode(), warehouse.getInternalCode(), warehouse.getWarehouseName(), warehouse.getWarehouseType(), warehouse.getCountryCode(), warehouse.getTimezone(), warehouse.getContactName(), warehouse.getContactPhone(), warehouse.getContactEmail(), warehouse.getStatus(), warehouse.getRemark(), warehouse.getCreatedAt(), warehouse.getUpdatedAt(), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java index 03e447e25..358fa93d5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -25,6 +25,8 @@ public class WarehouseEntity extends BaseEntity { private Long tenantId; @Schema(description = "Warehouse code / 仓库编码", example = "US-LAX-01") private String warehouseCode; + @Schema(description = "FBA internal code / FBA 内部编号", example = "154") + private String internalCode; @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓") private String warehouseName; @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS_PARTNER", diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 304268c00..055df51b4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -86,7 +86,9 @@ public class WarehouseRepository { .and(StringUtils.hasText(keyword), condition -> { condition.like(WarehouseEntity::getWarehouseCode, keyword) .or() - .like(WarehouseEntity::getWarehouseName, keyword); + .like(WarehouseEntity::getWarehouseName, keyword) + .or() + .like(WarehouseEntity::getInternalCode, keyword); if (!addressMatches.isEmpty()) { condition.or().in(WarehouseEntity::getId, addressMatches); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 0728ba84d..d6abc3986 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -168,6 +168,7 @@ public class WarehouseApplicationService { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setTenantId(request.tenantId()); warehouse.setWarehouseCode(request.warehouseCode()); + warehouse.setInternalCode(request.internalCode()); warehouse.setWarehouseName(request.warehouseName()); warehouse.setWarehouseType(request.warehouseType()); warehouse.setCountryCode(request.countryCode()); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java index 4608f050b..df89bc425 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java @@ -36,9 +36,9 @@ import java.util.Objects; public class WarehouseImportService { private static final int MAX_FILE_BYTES = 5 * 1024 * 1024; private static final int MAX_ROWS = 1000; - private static final String TEMPLATE_VERSION = "1"; + private static final String TEMPLATE_VERSION = "2"; private static final String[] HEADERS = { - "仓库编码*", "仓库名称", "仓库类型", "国家二字码", "省/州", "城市*", "区/县", + "仓库编码*", "内部编号", "仓库名称", "仓库类型", "国家二字码", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "时区", "状态", "联系人", "联系电话", "联系邮箱", "备注" }; @@ -56,7 +56,7 @@ public class WarehouseImportService { Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明"); instruction.createCell(1).setCellValue( - "默认仅新增;仓库类型默认 DOMESTIC,可填 OVERSEAS_PARTNER 表示海外合作仓;状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); + "默认仅新增;仓库类型默认 DOMESTIC,可填 OVERSEAS_PARTNER 表示海外合作仓;内部编号仅 FBA 仓可填,允许字母或数字;状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); Row header = sheet.createRow(2); for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); @@ -109,7 +109,8 @@ public class WarehouseImportService { value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), value(row, 6, formatter), value(row, 7, formatter), value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter), - value(row, 12, formatter), value(row, 13, formatter), value(row, 14, formatter))); + value(row, 12, formatter), value(row, 13, formatter), value(row, 14, formatter), + value(row, 15, formatter))); } } catch (IOException | RuntimeException exception) { return failure(safeFileName, resolvedMode, rows.size(), "文件", "文件格式无法读取,请使用标准 XLSX 模板"); @@ -197,6 +198,13 @@ public class WarehouseImportService { private void validateRequired(ImportRow row, List rows, List errors) { required(row, errors, "仓库编码", row.code(), 50); + optionalLength(row, errors, "内部编号", row.internalCode(), 16); + if (!row.internalCode().isBlank() && !row.internalCode().matches("[A-Za-z0-9]{1,16}")) { + errors.add(new WarehouseImportError(row.row(), "内部编号", "只允许填写 1 至 16 位字母或数字")); + } + if (!row.internalCode().isBlank() && !"FBA".equalsIgnoreCase(row.type())) { + errors.add(new WarehouseImportError(row.row(), "内部编号", "仅 FBA 仓可以填写")); + } if (row.name().isBlank() && !"FBA".equalsIgnoreCase(row.type())) { errors.add(new WarehouseImportError(row.row(), "仓库名称", "不能为空;FBA 仓可不填并默认使用仓库编码")); } else { @@ -238,6 +246,7 @@ public class WarehouseImportService { private static boolean unchanged(WarehouseEntity existing, WarehouseAddressEntity address, WarehouseSaveNormalizer.NormalizedSaveRequest normalized) { return Objects.equals(existing.getWarehouseName(), normalized.warehouseName()) + && Objects.equals(existing.getInternalCode(), normalized.internalCode()) && Objects.equals(existing.getWarehouseType(), normalized.warehouseType()) && Objects.equals(existing.getCountryCode(), normalized.countryCode()) && Objects.equals(existing.getTimezone(), normalized.timezone()) @@ -315,7 +324,7 @@ public class WarehouseImportService { } } - private record ImportRow(int row, String code, String name, String type, String countryCode, String stateProvince, + private record ImportRow(int row, String code, String internalCode, String name, String type, String countryCode, String stateProvince, String city, String district, String addressLine, String postalCode, String timezone, String status, String contactName, String contactPhone, String contactEmail, String remark) { WarehouseRequests.SaveRequest toCreateRequest() { @@ -327,7 +336,8 @@ public class WarehouseImportService { String warehouseType = StringUtils.hasText(type) ? type.trim().toUpperCase(Locale.ROOT) : null; String resolvedCountry = StringUtils.hasText(countryCode) ? countryCode.trim().toUpperCase(Locale.ROOT) : null; String resolvedName = StringUtils.hasText(name) ? name : code; - return new WarehouseRequests.SaveRequest(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, code, resolvedName, warehouseType, + return new WarehouseRequests.SaveRequest(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, code, + WarehouseSaveNormalizer.trimToNull(internalCode), resolvedName, warehouseType, resolvedCountry, WarehouseSaveNormalizer.trimToNull(timezone), WarehouseSaveNormalizer.trimToNull(resolvedContactName), WarehouseSaveNormalizer.trimToNull(resolvedContactPhone), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java index 69d09eacf..a44ed1c40 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java @@ -100,7 +100,16 @@ final class WarehouseSaveNormalizer { List capabilities = TYPE_FBA.equals(warehouseType) ? List.of() : request.capabilities(); - return new NormalizedSaveRequest(tenantId, request.warehouseCode().trim(), request.warehouseName().trim(), + String internalCode = trimToNull(request.internalCode()); + if (internalCode != null && !internalCode.matches("[A-Za-z0-9]{1,16}")) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "Internal code must contain 1 to 16 letters or digits / 内部编号必须为 1 至 16 位字母或数字"); + } + if (internalCode != null && !TYPE_FBA.equals(warehouseType)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "Internal code is only available for FBA warehouses / 内部编号仅允许 FBA 仓填写"); + } + return new NormalizedSaveRequest(tenantId, request.warehouseCode().trim(), internalCode == null ? null : internalCode.toUpperCase(Locale.ROOT), request.warehouseName().trim(), warehouseType, countryCode, timezone, trimToNull(request.contactName()), trimToNull(request.contactPhone()), trimToNull(request.contactEmail()), status, trimToNull(request.remark()), normalizedAddress, capabilities); } @@ -149,6 +158,7 @@ final class WarehouseSaveNormalizer { record NormalizedSaveRequest( long tenantId, String warehouseCode, + String internalCode, String warehouseName, String warehouseType, String countryCode, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index d6d41a8bf..5e782a8b6 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -14,7 +14,7 @@ import java.util.List; @Service public class WarehouseSchemaReadinessService { private static final List PROBES = List.of( - "SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0", + "SELECT id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0", "SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0", "SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0", "SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0", diff --git a/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv b/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv index 5f4846717..cebe3f970 100644 --- a/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv +++ b/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv @@ -1,253 +1,315 @@ source_row,warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id -238,193000000000000001,193000000000100001,ABE2,ABE2,US,PA,Breinigsville,18031,UTC-5,705 Boulder Drive, -237,193000000000000002,193000000000100002,ABE3,ABE3,US,PA,Breinigsville,18031,UTC-5,650 Boulder Drive, -134,193000000000000003,193000000000100003,ABE4,ABE4,US,PA,Easton,18045,UTC-5,1610 Van Buren Road,126 -231,193000000000000004,193000000000100004,ABE5,ABE5,US,PA,Harrisburg,17112,UTC-5,6455 Allentown Boulevard, -125,193000000000000005,193000000000100005,ABE8,ABE8,US,NJ,Florence,08518,UTC-5,401 Independence Road 08518-2200,39 -99,193000000000000006,193000000000100006,ABQ2,ABQ2,US,NM,LOS LUNAS,87031,UTC-7,6251 Pioneer Trail NW,76 -84,193000000000000007,193000000000100007,ACY2,ACY2,US,NJ,BURLINGTON,08016,UTC-5,1101 E PEARL ST,101 -141,193000000000000008,193000000000100008,AFW1,AFW1,US,TX,FORT WORTH,76131,UTC-6,1851 NE LOOP 820, -27,193000000000000009,193000000000100009,AFW5,AFW5,US,TX,BALCH SPRINGS,75180-1636,UTC-6,12374 LAKE JUNE RD,181 -148,193000000000000010,193000000000100010,AKR1,AKR1,US,OH,Canton,44705,UTC-5,4747 Rebar Ave NE,153 -174,193000000000000011,193000000000100011,ALB1,ALB1,US,NY,Castleton,12033-9634,UTC-5,1835 US Route 9,102 -129,193000000000000012,193000000000100012,AMA1,AMA1,US,TX,Amarillo,79108,UTC-6,8590 NE 24th Avenue,132 -31,193000000000000013,193000000000100013,ATL2N,ATL2N,US,GA,Carrollton,30117,UTC-5,3101 N. Highway 27,157 -147,193000000000000014,193000000000100014,ATL3,ATL3,US,GA,Pendergrass,30567,UTC-5,445 Valentine Industrial Parkway, -202,193000000000000015,193000000000100015,ATL6,ATL6,US,GA,East Point,30344,UTC-5,4200 North Commerce, -233,193000000000000016,193000000000100016,AVP1,AVP1,US,PA,Hazle Township,18202-9361,UTC-5,550 Oak Ridge Road,70 -232,193000000000000017,193000000000100017,AVP3,AVP3,US,PA,GOULDSBORO,18424-9492,UTC-5,298 1ST AVE, -289,193000000000000018,193000000000100018,BDL1,BDL1,US,CT,Windsor,06095,UTC-5,801 Day Hill Rd., -208,193000000000000019,193000000000100019,BFI1,BFI1,US,WA,Sumner,98390,UTC-8,1800 140th Avenue E., -207,193000000000000020,193000000000100020,BFI3,BFI3,US,WA,Dupont,98327-9607,UTC-8,2700 Center Drive,34 -206,193000000000000021,193000000000100021,BFI7,BFI7,US,WA,Sumner,98390,UTC-8,1901 140th Ave E, -11,193000000000000022,193000000000100022,BJC1,BJC1,US,CO,AURORA,80019,UTC-7,6300 N Powhaton Road,186 -221,193000000000000023,193000000000100023,BNA1,BNA1,US,TN,Lebanon,37090,UTC-6,14840 Central Pike Suite 190, -131,193000000000000024,193000000000100024,BNA2,BNA2,US,TN,Lebanon,37090,UTC-6,500 Duke DR,37 -140,193000000000000025,193000000000100025,BNA3,BNA3,US,TN,Murfreesboro,37127,UTC-6,2020 Joe B Jackson Pkwy, -220,193000000000000026,193000000000100026,BNA4,BNA4,US,TN,Nashville,37217,UTC-6,50 airways Blvd, -110,193000000000000027,193000000000100027,BNA6,BNA6,US,TN,CLARKSVILLE,37040-5502,UTC-6,3875 GUTHRIE HWY,99 -243,193000000000000028,193000000000100028,BOS1,BOS1,US,NH,Nashua,03063,UTC-5,10 State St., -193,193000000000000029,193000000000100029,BOS2,BOS2,US,MA,Drive Stoughton,02072,UTC-5,1000 Tech Centre, -192,193000000000000030,193000000000100030,BOS7,BOS7,US,MA,Fall River,02720,UTC-5,1180 Innovation Way,103 -242,193000000000000031,193000000000100031,BWI1,BWI1,US,VA,Sterling,20166-2058,UTC-5,45121 Global Plaza, -241,193000000000000032,193000000000100032,BWI2,BWI2,US,MD,Baltimore,21224,UTC-5,2010 Broening Hwy, -102,193000000000000033,193000000000100033,BWI4,BWI4,US,VA,CLEARBROOK,22624,UTC-5,165 Business Blvd,150 -225,193000000000000034,193000000000100034,CAE1,CAE1,US,SC,West Columbia,29172,UTC-5,4400 12TH STREET EXT, -223,193000000000000035,193000000000100035,CHA1,CHA1,US,TN,Chattanooga,37416,UTC-5,7200 Discovery Drive, -222,193000000000000036,193000000000100036,CHA2,CHA2,US,TN,Charleston,37310,UTC-5,225 Infinity Dr NW,66 -75,193000000000000037,193000000000100037,CHO1,CHO1,US,VA,FISHERSVILLE,22939,UTC-5,32 TRADER RD,120 -191,193000000000000038,193000000000100038,CLT2,CLT2,US,NC,Charlotte,28214-8082,UTC-5,10240 Old Dowd Rd,59 -114,193000000000000039,193000000000100039,CLT3,CLT3,US,NC,CONCORD,28027,UTC-5,6500 Davidson Hwy,104 -240,193000000000000040,193000000000100040,CMH2,CMH2,US,OH,Groveport,43125-9016,UTC-5,6050 Gateway Court,123 -181,193000000000000041,193000000000100041,CMH3,CMH3,US,OH,Monroe,45050-1848,UTC-5,700 GATEWAY BLVD,32 -262,193000000000000042,193000000000100042,CVG1,CVG1,US,KY,Hebron,41048,UTC-5,1155 Worldwide Blvd, -261,193000000000000043,193000000000100043,CVG2,CVG2,US,KY,Hebron,41048,UTC-5,1600 Worldwide Blvd, -260,193000000000000044,193000000000100044,CVG3,CVG3,US,KY,Hebron,41048,UTC-5,3680 Langley Dr., -259,193000000000000045,193000000000100045,CVG5,CVG5,US,KY,Hebron,41048,UTC-5,2285 Litton Lane, -135,193000000000000046,193000000000100046,DCA6,DCA6,US,MD,BALTIMORE,21219,UTC-5,6001 Bethlehem Blvd,135 -123,193000000000000047,193000000000100047,DEN2,DEN2,US,CO,Aurora,80019,UTC-7,22205 East 19th Ave,107 -120,193000000000000048,193000000000100048,DEN8,DEN8,US,CO,Aurora,80018,UTC-7,21000 E 13th Ave,73 -189,193000000000000049,193000000000100049,DET1,DET1,US,MI,Livonia,48150-1543,UTC-5,39000 Amrhein Road,118 -182,193000000000000050,193000000000100050,DET2,DET2,US,MI,Shelby Township,48317-1318,UTC-5,50500 Mound Rd,136 -143,193000000000000051,193000000000100051,DFW5S,DFW5S,US,TX,Lancaster,75146,UTC-6,"2500 E Belt Line Rd,", -219,193000000000000052,193000000000100052,DFW6,DFW6,US,TX,Coppell,75019,UTC-6,940 W Bethel Road,105 -217,193000000000000053,193000000000100053,DFW8,DFW8,US,TX,DFW Airport,75261,UTC-6,2700 Regent Blvd, -256,193000000000000054,193000000000100054,EWR4,EWR4,US,NJ,Robbinsville,08691,UTC-5,50 New Canton Way, -255,193000000000000055,193000000000100055,EWR5,EWR5,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., -254,193000000000000056,193000000000100056,EWR7,EWR7,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., -253,193000000000000057,193000000000100057,EWR9,EWR9,US,NJ,Carteret,07008,UTC-5,8003 Industrial Parkway, -117,193000000000000058,193000000000100058,FAT2,FAT2,US,CA,VISALIA,93291-1882,UTC-8,3315 N KELSEYST,43 -176,193000000000000059,193000000000100059,FOE1,FOE1,US,KS,KANSAS CITY,66109-3665,UTC-6,9400 LEAVENWORTH RD,117 -212,193000000000000060,193000000000100060,FTW1,FTW1,US,TX,Dallas,75241,UTC-6,33333 Lyndon B Johnson FREEWAY,24 -211,193000000000000061,193000000000100061,FTW2,FTW2,US,TX,Coppell,75019,UTC-6,2701 West Bethel Road, -89,193000000000000062,193000000000100062,FTW3,FTW3,US,TX,Fort Worth,76177-2517,UTC-6,15201 Heritage Pkwy, -121,193000000000000063,193000000000100063,FTW5,FTW5,US,TX,Forney,75126,UTC-6,1475 AKRON WAY,48 -171,193000000000000064,193000000000100064,FWA4,FWA4,US,IN,FORT WAYNE,46809-9771,UTC-5,9798 Smith Road,58 -151,193000000000000065,193000000000100065,GEG2,GEG2,US,WA,SPOKANE,99216,UTC-8,18007 E Garland Ave,108 -67,193000000000000066,193000000000100066,GEU2,GEU2,US,AZ,Glendale,85307,UTC-7,15301 West Northern Avenue,97 -78,193000000000000067,193000000000100067,GEU3,GEU3,US,AZ,BUCKEYE,85396,UTC-7,18900 W McDowell Road,81 -68,193000000000000068,193000000000100068,GEU5,GEU5,US,AZ,GOODYEAR,85395,UTC-7,4660 North Cotton Lane,100 -164,193000000000000069,193000000000100069,GSO1,GSO1,US,NC,KERNERSVILLE,27284,UTC-5,1656 OLD GREENSBORO RD,131 -224,193000000000000070,193000000000100070,GSP1,GSP1,US,SC,Spartanburg,29303,UTC-5,402 John Dodd Rd,90 -188,193000000000000071,193000000000100071,GYR2,GYR2,US,AZ,GOODYEAR,85395-7672,UTC-7,17341 W MINNEZONA AVE,7 -187,193000000000000072,193000000000100072,GYR3,GYR3,US,AZ,PHOENIX,85043-2356,UTC-7,8181 W ROOSEVELT ST,8 -80,193000000000000073,193000000000100073,HGR6,HGR6,US,MD,HAGERSTOWN,21740-7301,UTC-5,55 West Oak Ridge Drive,130 -87,193000000000000074,193000000000100074,HIA1,HIA1,US,PA,MIDDLETOWN,17057,UTC-5,3327 E Harrisburg Pik,148 -44,193000000000000075,193000000000100075,HLI2,HLI2,US,CA,HOLLISTER,95023,UTC-8,600 Ernie Drive,137 -216,193000000000000076,193000000000100076,HOU1,HOU1,US,TX,Humble,77338,UTC-6,8120 Humble Westfield Rd, -215,193000000000000077,193000000000100077,HOU2,HOU2,US,TX,Houston,77038-2324,UTC-6,10550 Ella Blvd., -214,193000000000000078,193000000000100078,HOU3,HOU3,US,TX,Brookshire,77423,UTC-6,31555 Highway 90 E, -154,193000000000000079,193000000000100079,HOU8,HOU8,US,TX,FRESNO,77545,UTC-6,2303 Hurricane Lane,49 -142,193000000000000080,193000000000100080,HSV1,HSV1,US,AL,Madison,35756,UTC-6,7817 Greenbrier Road,80 -126,193000000000000081,193000000000100081,HSV2,HSV2,US,AL,MADISON,35756,UTC-6,28869 Fanning Dr NW, -155,193000000000000082,193000000000100082,IAH3,IAH3,US,TX,Houston,77032,UTC-6,15525 Milner Road,57 -3,193000000000000083,193000000000100083,IAZ1,IAZ1,US,AZ,GOODYEAR,85395-4515,UTC-7,"4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States",179 -136,193000000000000084,193000000000100084,ICT2,ICT2,US,KS,PARK CITY,67219,UTC-6,7130 N BROADWAY AVE,109 -2,193000000000000085,193000000000100085,IGA3,IGA3,US,GA,MACON,31216-6427,UTC-5,"7001 Skipper Rd 31216-6427 MACON, GA - United States", -152,193000000000000086,193000000000100086,IGQ2,IGQ2,US,IL,University park,60484,UTC-6,23257 S. Central Avenue,110 -119,193000000000000087,193000000000100087,ILG1,ILG1,US,DE,NEW CASTLE,19720,UTC-5,780 S. DuPont Highway,35 -10,193000000000000088,193000000000100088,IMO1,IMO1,US,MO,LIBERTY,64068-8933,UTC-6,2361 S Withers RD,178 -6,193000000000000089,193000000000100089,IMS1,IMS1,US,MS,OLIVE BRANCH,38654,UTC-6,IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US,189 -5,193000000000000090,193000000000100090,INC1,INC1,US,NC,FAYETTEVILLE,28303-2215,UTC-5,"6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States",190 -88,193000000000000091,193000000000100091,IND2,IND2,US,IN,Plainfield,46168-7442,UTC-5,715 Airtech Pkwy,127 -276,193000000000000092,193000000000100092,IND4,IND4,US,IN,Indianapolis,46231,UTC-5,710 S. Girls School Rd, -275,193000000000000093,193000000000100093,IND5,IND5,US,IN,Plainfield,46168,UTC-5,800 Perry Road,140 -274,193000000000000094,193000000000100094,IND6,IND6,US,IN,Jeffersonville,47130,UTC-5,1453 E 10th St, -173,193000000000000095,193000000000100095,IND9,IND9,US,IN,GREENWOOD,46143-7830,UTC-5,1151 S GRAHAM RD,17 -9,193000000000000096,193000000000100096,ITX3,ITX3,US,TX,AMARILLO,79108-7749,UTC-6,"ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US",187 -22,193000000000000097,193000000000100097,IUSF,IUSF,US,TX,DALLAS,75241,UTC-6,3351 Balmorhea Dr,169 -69,193000000000000098,193000000000100098,IUSJ,IUSJ,US,CA,BEAUMONT,92223,UTC-8,36900 West 4th Street,98 -74,193000000000000099,193000000000100099,IUSP,IUSP,US,CA,"HESPERIA,",92344,UTC-8,8140 Caliente Rd,83 -70,193000000000000100,193000000000100100,IUSQ,IUSQ,US,CA,REDLANDS,92374,UTC-8,2125 W San Bernardino Ave,95 -40,193000000000000101,193000000000100101,IUSR,IUSR,US,SC,COWPENS,29330,UTC-5,1120 Mt Olive Rd,158 -39,193000000000000102,193000000000100102,IUST,IUST,US,PA,GREENCASTLE,17225,UTC-5,"3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States",149 -33,193000000000000103,193000000000100103,IUSW,IUSW,US,CA,Hesperia,92344,UTC-8,"8130 Caliente Rd 92344 - Hesperia, CA - United States",156 -21,193000000000000104,193000000000100104,IUTE,IUTE,US,AZ,GOODYEAR,85338,UTC-7,16155 W Elwood St,170 -8,193000000000000105,193000000000100105,IUTJ,IUTJ,US,CA,Fontana,92335,UTC-8,"9253 Dreamland Drive 92335 - Fontana, CA - United",177 -7,193000000000000106,193000000000100106,IUTL,IUTL,US,CA,Moreno Valley,92551,UTC-8,"24405 Krameria Ave 92551 - Moreno Valley, CA - United States",188 -4,193000000000000107,193000000000100107,IWA6,IWA6,US,WA,PASCO,99301-6161,UTC-8,"1202 S Road 40 E 99301-6161 - PASCO, WA - United States",191 -72,193000000000000108,193000000000100108,JOT1,JOT1,US,CA,SAN BERNARDINO,92408,UTC-8,1494 S WATERMAN AVE,93 -150,193000000000000109,193000000000100109,JVL1,JVL1,US,WI,BELOIT,53511,UTC-6,1255 Gateway Blvd,125 -178,193000000000000110,193000000000100110,KRB4,KRB4,US,CA,PERRIS,92571-9722,UTC-8,4501 PATTERSON AVE, -161,193000000000000111,193000000000100111,KY1,KY1,US,KY,Shepherdsville,40165,UTC-5,"120 Velocity Way.,",60 -100,193000000000000112,193000000000100112,LAN2,LAN2,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy,96 -247,193000000000000113,193000000000100113,LAS1,LAS1,US,NV,HENDERSON,89044,UTC-8,12300 Bermuda Road,5 -246,193000000000000114,193000000000100114,LAS2,LAS2,US,NV,Las Vegas,89030,UTC-8,"3837 Bay Lake Trail, Suite 111 North", -132,193000000000000115,193000000000100115,LAS6,LAS6,US,NV,Las Vegas,89115,UTC-8,4550 Nexus Way,50 -162,193000000000000116,193000000000100116,LAX2T,LAX2T,US,CA,CHINO,91708,UTC-8,6730 Kimball Ave,19 -183,193000000000000117,193000000000100117,LAX9,LAX9,US,CA,FONTANA,92337,UTC-8,11263 Oleander Ave,3 -86,193000000000000118,193000000000100118,LBE1,LBE1,US,PA,Greensburg,15605,UTC-5,165 Glen Fox Road,112 -268,193000000000000119,193000000000100119,LEX1,LEX1,US,KY,Lexington,40511,UTC-5,1850 Mercer RD, -267,193000000000000120,193000000000100120,LEX2,LEX2,US,KY,Lexington,40511,UTC-5,172 Trade St., -107,193000000000000121,193000000000100121,LFT1,LFT1,US,LA,CARENCRO,70520-5976,UTC-6,3550 NE EVANGELINE TRWY,31 -291,193000000000000122,193000000000100122,LGB3,LGB3,US,CA,Eastvale,91752,UTC-8,4950 Goodman Way, -292,193000000000000123,193000000000100123,LGB4,LGB4,US,CA,Redlands,92374,UTC-8,27517 Pioneer Avenue,45 -293,193000000000000124,193000000000100124,LGB6,LGB6,US,CA,Riverside,92518-1513,UTC-8,20901 Krameria Ave,26 -294,193000000000000125,193000000000100125,LGB8,LGB8,US,CA,Rialto,92376,UTC-8,1568 N Linden Ave,2 -106,193000000000000126,193000000000100126,LIT2,LIT2,US,AR,NORTH LITTLE ROCK,72117,UTC-6,13001 HIGHWAY 70,159 -32,193000000000000127,193000000000100127,MCC1,MCC1,US,CA,RANCHO CORDOVA,95742,UTC-8,"3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States",161 -157,193000000000000128,193000000000100128,MCE1,MCE1,US,CA,TURLOCK,95380,UTC-8,FULKERTH RD,65 -271,193000000000000129,193000000000100129,MCI1,MCI1,US,KS,Lenexa,66219,UTC-6,113th St & Renner Blvd., -12,193000000000000130,193000000000100130,MCI4,MCI4,US,MO,KANSAS CITY,64163,UTC-6,MCI4 - 10501 NW 136th Street,176 -115,193000000000000131,193000000000100131,MCO2,MCO2,US,FL,Deltona,32725-2610,UTC-5,2600 N Normandy Blvd,106 -230,193000000000000132,193000000000100132,MDT1,MDT1,US,PA,Carlisle,17015,UTC-5,2Ames Drive,121 -128,193000000000000133,193000000000100133,MDT4,MDT4,US,PA,York,17408,UTC-5,5125 Commerce Drive,144 -200,193000000000000134,193000000000100134,MDW2,MDW2,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive,46 -199,193000000000000135,193000000000100135,MDW6,MDW6,US,IL,ROMEOVILLE,60446-6529,UTC-6,1125 W REMINGTON BLVD ROMEOVILLE,143 -198,193000000000000136,193000000000100136,MDW7,MDW7,US,IL,Monee,60449,UTC-6,6605 Monee Manhattan Road, -197,193000000000000137,193000000000100137,MDW8,MDW8,US,IL,Waukegan,60085,UTC-6,1750 Bridge Drive, -196,193000000000000138,193000000000100138,MDW9,MDW9,US,IL,Aurora,60502,UTC-6,2865 Duke Parkway,36 -163,193000000000000139,193000000000100139,MEM1,MEM1,US,TN,Memphis,38118,UTC-6,3292 E Holmes Rd,85 -118,193000000000000140,193000000000100140,MEM6,MEM6,US,MS,OLIVE BRANCH,38654,UTC-6,11505 Progress Way,51 -201,193000000000000141,193000000000100141,MGE3,MGE3,US,GA,Jefferson,30549-2010,UTC-5,808 Hog Mountain Road,133 -77,193000000000000142,193000000000100142,MIT2,MIT2,US,CA,SHAFTER,93263,UTC-8,5408 Express Avenue,82 -270,193000000000000143,193000000000100143,MKC4,MKC4,US,KS,Edgerton,66021-9588,UTC-6,19645 Waverly Rd,67 -269,193000000000000144,193000000000100144,MKC6,MKC6,US,KS,Kansas City,66102,UTC-6,6925 Riverview Avenue, -203,193000000000000145,193000000000100145,MKE1,MKE1,US,WI,Kenosha,53144,UTC-6,3501 120th Ave, -94,193000000000000146,193000000000100146,MQJ1,MQJ1,US,IN,GREENFIELD,46140,UTC-5,4412 W 300 RD N,88 -190,193000000000000147,193000000000100147,MSP1,MSP1,US,MN,Shakopee,55379,UTC-6,2601 4th Avenue East, -17,193000000000000148,193000000000100148,NJ3,NJ3,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way,175 -299,193000000000000149,193000000000100149,OAK3,OAK3,US,CA,Patterson,95363-8876,UTC-8,255 Park Center Drive,41 -300,193000000000000150,193000000000100150,OAK4,OAK4,US,CA,Tracy,95304-9370,UTC-8,1555 N. Chrisman Rd, -133,193000000000000151,193000000000100151,OKC2,OKC2,US,OK,OKLAHOMA CITY,73159,UTC-6,8991 S PORTLAND AVENUE,33 -19,193000000000000152,193000000000100152,OLM1,OLM1,US,WA,OLYMPIA,98516,UTC-8,"3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States",172 -303,193000000000000153,193000000000100153,ONT2,ONT2,US,CA,San Bernardino,92408,UTC-8,1910 E Central Ave, -302,193000000000000154,193000000000100154,ONT6,ONT6,US,CA,Moreno Valley,92551-9561,UTC-8,24208 San Michele Rd, -301,193000000000000155,193000000000100155,ONT8,ONT8,US,CA,Moreno Valley,92551-9534,UTC-8,24300 Nandina Ave,1 -298,193000000000000156,193000000000100156,ONT9,ONT9,US,CA,Redlands,92374,UTC-8,2125 W. San Bernandino Ave,25 -103,193000000000000157,193000000000100157,ORD2,ORD2,US,IL,CHANNAHON,60410-8758,UTC-6,23714 W Amoco Rd,21 -195,193000000000000158,193000000000100158,ORD6,ORD6,US,IL,Wood Dale,60191,UTC-6,1250 N Mittel Blvd, -113,193000000000000159,193000000000100159,ORF2,ORF2,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD,38 -81,193000000000000160,193000000000100160,PBI3,PBI3,US,FL,Jacksonville,32206,UTC-5,21 Jump Street,113 -127,193000000000000161,193000000000100161,PDX7,PDX7,US,OR,Salem,97317,UTC-8,4775 Depot Ct SE,68 -288,193000000000000162,193000000000100162,PHL1,PHL1,US,DE,New Castle,19720,UTC-5,1 Centerpoint Blvd., -287,193000000000000163,193000000000100163,PHL3,PHL3,US,DE,New Castle,19720,UTC-5,1600 Johnson Way, -236,193000000000000164,193000000000100164,PHL4,PHL4,US,PA,Carlisle,17015,UTC-5,21 Roadway Dr,52 -235,193000000000000165,193000000000100165,PHL5,PHL5,US,PA,Lewisberry,17339,UTC-5,500 McCarthy Dr, -20,193000000000000166,193000000000100166,PHL5S,PHL5S,US,PA,Greencastle,17225,UTC-5,"1915 Ebberts Spg Ct, Greencastle, PA 17225",171 -234,193000000000000167,193000000000100167,PHL6,PHL6,US,PA,Carlisle,17015,UTC-5,675 Allen Rd.,69 -286,193000000000000168,193000000000100168,PHL7,PHL7,US,DE,Middletown,19709,UTC-5,560 Merrimac Ave, -285,193000000000000169,193000000000100169,PHL8,PHL8,US,DE,Middletown,19709,UTC-5,727 N Broad St, -307,193000000000000170,193000000000100170,PHX3,PHX3,US,AZ,Phoenix,85043,UTC-7,6835 West Buckeye Road, -306,193000000000000171,193000000000100171,PHX5,PHX5,US,AZ,Goodyear,85338,UTC-7,16920 W Commerce Drive,72 -305,193000000000000172,193000000000100172,PHX6,PHX6,US,AZ,Phoenix,85043,UTC-7,4750 West Mohave St, -304,193000000000000173,193000000000100173,PHX7,PHX7,US,AZ,Phoenix,85043,UTC-7,800 N 75th Ave,74 -229,193000000000000174,193000000000100174,PIT1,PIT1,US,PA,Pittsburgh,15205,UTC-5,2250 Roswell Drive, -158,193000000000000175,193000000000100175,PIT2,PIT2,US,PA,IMPERIAL,15126-1002,UTC-5,1200 Westport Rd,78 -46,193000000000000176,193000000000100176,POC1,POC1,US,CA,FONTANA,92336-1123,UTC-8,4121 LYTLE CREEK RD,128 -38,193000000000000177,193000000000100177,POC2,POC2,US,CA,ONTARIO,91761,UTC-8,4000 South Hamner Avenue,151 -45,193000000000000178,193000000000100178,POC3,POC3,US,CA,JURUPA VALLEY,92509,UTC-8,6120 Clinker Drive,129 -49,193000000000000179,193000000000100179,PPO4,PPO4,US,IN,MERRILLVILLE,46410,UTC-6,9850 Mississippi Street,122 -93,193000000000000180,193000000000100180,PSC2,PSC2,US,WA,PASCO,99301,UTC-8,1351 S Road 40 E,75 -28,193000000000000181,193000000000100181,PSP3,PSP3,US,CA,DESERT HOT SPRINGS,92240,UTC-8,64165 19th AVE,162 -71,193000000000000182,193000000000100182,QXY5,QXY5,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,92 -35,193000000000000183,193000000000100183,QXY8,QXY8,US,CA,FRESNO,93725-1962,UTC-8,3611 S NORTHPOINTE DR,154 -14,193000000000000184,193000000000100184,QZZ7,QZZ7,US,AZ,MESA,85212-9608,UTC-7,8560 E ELLIOT RD,184 -146,193000000000000185,193000000000100185,RDU2,RDU2,US,NC,Smithfield,27577,UTC-5,2150 US HWY 70 Business West,56 -95,193000000000000186,193000000000100186,RDU4,RDU4,US,NC,FAYETTEVILLE,28303,UTC-5,6309 Bragg Blvd,145 -145,193000000000000187,193000000000100187,RFD2,RFD2,US,IL,Huntley,60142,UTC-6,11500 Freeman Road,71 -16,193000000000000188,193000000000100188,RFD4,RFD4,US,IL,HUNTLEY,60142,UTC-6,"11400 Venture Court 60142-8075 - HUNTLEY, IL - United States",174 -210,193000000000000189,193000000000100189,RIC1,RIC1,US,VA,Petersburg,23803,UTC-5,5000 Commerce Way,142 -209,193000000000000190,193000000000100190,RIC2,RIC2,US,VA,Chester,23836,UTC-5,1901 Meadowville Technology Pkwy, -144,193000000000000191,193000000000100191,RMN3,RMN3,US,VA,Fredericksburg,22406,UTC-5,220 Centreport Parkway,84 -248,193000000000000192,193000000000100192,RNO1,RNO1,US,NV,Fernley,89408,UTC-8,1600 East Newlands RD, -245,193000000000000193,193000000000100193,RNO2,RNO2,US,NV,Reno,85906,UTC-8,8000 North Virginia Street, -244,193000000000000194,193000000000100194,RNO4,RNO4,US,NV,Reno,89506,UTC-8,8000 North Virginia Street, -73,193000000000000195,193000000000100195,RYY2,RYY2,US,GA,White,30184,UTC-5,"1250 Cassville White Road,",94 -218,193000000000000196,193000000000100196,SAT1,SAT1,US,TX,Schertz,78154-1461,UTC-6,6000 Enterprise Avenue,23 -149,193000000000000197,193000000000100197,SAT4,SAT4,US,TX,SAN ANTONIO,78245,UTC-6,10384 W US HIGHWAY 90,61 -15,193000000000000198,193000000000100198,SAT6,SAT6,US,TX,SAN MARCOS,78666,UTC-6,"1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States",183 -98,193000000000000199,193000000000100199,SAV3,SAV3,US,GA,Macon,31216-6427,UTC-5,7001 Skipper Rd,124 -185,193000000000000200,193000000000100200,SBD1,SBD1,US,CA,BLOOMINGTON,92316-3819,UTC-8,3388 S Cactus Ave,4 -179,193000000000000201,193000000000100201,SBD2,SBD2,US,CA,SAN BERNARDINO,92408-2805,UTC-8,1494 S WATERMAN AVE,15 -97,193000000000000202,193000000000100202,SCK1,SCK1,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,20 -60,193000000000000203,193000000000100203,SCK3,SCK3,US,CA,MANTECA,95336,UTC-8,3565 N AIRPORT WAY MANTECA,54 -186,193000000000000204,193000000000100204,SCK4,SCK4,US,CA,STOCKTON,95215-8354,UTC-8,6001 S AUSTIN RD,10 -104,193000000000000205,193000000000100205,SCK8,SCK8,US,CA,OAKLEY,94561-2677,UTC-8,4700 WILBUR AVE,141 -266,193000000000000206,193000000000100206,SDF1,SDF1,US,KY,Campbellsville,42718,UTC-5,1050 South Columbia AVE, -265,193000000000000207,193000000000100207,SDF2,SDF2,US,KY,Watterson Park,40218,UTC-5,4360 Robards Ln, -264,193000000000000208,193000000000100208,SDF4,SDF4,US,KY,Shepherdsville,40165,UTC-5,376 Zappos.com Blvd, -263,193000000000000209,193000000000100209,SDF6,SDF6,US,KY,Shepherdsville,40165,UTC-5,271 Omega Pkwy, -258,193000000000000210,193000000000100210,SDF7,SDF7,US,KY,Shepherdsville,40165,UTC-5,300 Omicron Court, -273,193000000000000211,193000000000100211,SDF8,SDF8,US,IN,Jeffersonville,47130,UTC-5,900 Patrol Rd, -257,193000000000000212,193000000000100212,SDF9,SDF9,US,KY,Shepherdsville,40165,UTC-5,100 W. Thomas P. Echols Lane, -205,193000000000000213,193000000000100213,SEA6,SEA6,US,WA,South Seattle,98144,UTC-8,2646 Rainier Ave., -204,193000000000000214,193000000000100214,SEA8,SEA8,US,WA,Northeast Bellevue,98005,UTC-8,1475 124th Avenue, -295,193000000000000215,193000000000100215,SJC7,SJC7,US,CA,Tracy,95377-8906,UTC-8,188 Mountain House Parkway,30 -153,193000000000000216,193000000000100216,SLC2,SLC2,US,UT,West Jordan,84081,UTC-7,7148 W. Old Bingham Hwy,86 -13,193000000000000217,193000000000100217,SLC3,SLC3,US,UT,SALT LAKE CITY,84116-4413,UTC-7,355 N JOHN GLENN RD,185 -184,193000000000000218,193000000000100218,SMF3,SMF3,US,CA,Stockton,95206-8202,UTC-8,3923 S B ST,9 -105,193000000000000219,193000000000100219,SMF6,SMF6,US,CA,SACRAMENTO,95837-9109,UTC-8,4930 ALLBAUGH DR,28 -296,193000000000000220,193000000000100220,SNA4,SNA4,US,CA,Rialto,92376-3009,UTC-8,2496 W Walnut Ave,18 -109,193000000000000221,193000000000100221,STL3,STL3,US,MO,REPUBLIC,65738-2779,UTC-6,3200 E SAWYER RD,152 -194,193000000000000222,193000000000100222,STL4,STL4,US,IL,Edwardsville,62025-2815,UTC-6,3050 GATEWAY COMMERCE CENTER DR S,115 -122,193000000000000223,193000000000100223,SWF1,SWF1,US,NY,ROCK TAVERN,12575,UTC-5,635 INTERNATIONAL BLVD,134 -76,193000000000000224,193000000000100224,SWF2,SWF2,US,NY,Hopewell Junction,12533,UTC-5,76 Patriot Way Hopewell Junction,62 -41,193000000000000225,193000000000100225,TCY1,TCY1,US,CA,STOCKTON,95206,UTC-8,2690 East Arch Airport Road,139 -43,193000000000000226,193000000000100226,TCY2,TCY2,US,CA,STOCKTON,95215,UTC-8,6201 Newcastle Rd,138 -250,193000000000000227,193000000000100227,TEB3,TEB3,US,NJ,Logan Township,08085,UTC-5,2651 Oldmans Creek Rd,47 -130,193000000000000228,193000000000100228,TEB4,TEB4,US,NJ,PENNS GROVE,08069,UTC-5,747 COURSES LANDING RD,55 -249,193000000000000229,193000000000100229,TEB6,TEB6,US,NJ,Cranbury,08512,UTC-5,22 Hightstown Cranbury State Road,114 -156,193000000000000230,193000000000100230,TEB9,TEB9,US,NJ,Somerset,08873,UTC-5,601 Randolph Road,40 -65,193000000000000231,193000000000100231,TMB8,TMB8,US,FL,HOMESTEAD,33032-8597,UTC-5,27505 SW 132nd AVE,116 -18,193000000000000232,193000000000100232,TOL3,TOL3,US,OH,PERRYSBURG,43551,UTC-5,"9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States",173 -284,193000000000000233,193000000000100233,TPA1,TPA1,US,FL,Ruskin,33570,UTC-5,3350 Laurel Ridge Ave., -283,193000000000000234,193000000000100234,TPA2,TPA2,US,FL,Lakeland,33811,UTC-5,1760 County Line Rd,22 -124,193000000000000235,193000000000100235,TPA3,TPA3,US,FL,Auburndale,33823,UTC-5,676 C. Fred Jones Boulevard,146 -272,193000000000000236,193000000000100236,TUL1,TUL1,US,KS,Coffeyville,67337,UTC-6,2654 North Highway 169, -172,193000000000000237,193000000000100237,VGT2,VGT2,US,NV,LAS VEGAS,89115-2013,UTC-8,6401 Howdy Wells Ave,6 -228,193000000000000238,193000000000100238,VUBA,VUBA,US,PA,Scranton,18512,UTC-5,1000 Keystone Industrial Park, -227,193000000000000239,193000000000100239,VUGA,VUGA,US,PA,Pittston,18643,UTC-5,508 Delaware Avenue West, -82,193000000000000240,193000000000100240,WBW2,WBW2,US,PA,OLYPHANT,18447,UTC-5,1300 Corporate Way,119 -36,193000000000000241,193000000000100241,XAV3,XAV3,US,GA,PORT WENTWORTH,31407,UTC-5,"XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States",180 -37,193000000000000242,193000000000100242,XEW5,XEW5,US,NJ,Cinnaminson,08077-2015,UTC-5,995 Taylors Ln 08077-2015,163 -25,193000000000000243,193000000000100243,XIN5,XIN5,US,IN,Avon,46123-1700,UTC-5,8838 E COUNTY ROAD 100 S,182 -34,193000000000000244,193000000000100244,XLX1,XLX1,US,MD,HAGERSTOWN,21740,UTC-5,"1115 Wesel Blvd HAGERSTOWN,MD 21740,USA",160 -23,193000000000000245,193000000000100245,XLX6,XLX6,US,NC,PINEVILLE,28134,UTC-5,"12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA",166 -63,193000000000000246,193000000000100246,XLX7,XLX7,US,CA,VICTORVILLE,92394,UTC-8,"18580 Gateway Dr,Victorville,CA 92394,USA",27 -26,193000000000000247,193000000000100247,XPB2,XPB2,US,FL,APOPKA,32712,UTC-5,"XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States",165 -24,193000000000000248,193000000000100248,XRI3,XRI3,US,VA,Richmond,23234,UTC-5,"4949 Commerce Rd, Richmond, VA 23234",167 -213,193000000000000249,193000000000100249,XUSB,XUSB,US,TX,Fort Worth,76155,UTC-6,14900 Frye Rd., -226,193000000000000250,193000000000100250,XUSC,XUSC,US,PA,Carlisle,17013,UTC-5,40 Logistics Drive, -297,193000000000000251,193000000000100251,XUSD,XUSD,US,CA,Stockton,95206,UTC-8,1909 Zephyr St, -280,193000000000000252,193000000000100252,XUSE,XUSE,US,IN,Whitestown,46075,UTC-5,5100 S Indianapolis Rd, +246,193000000000000001,193000000000100001,ABE2,ABE2,US,PA,Breinigsville,18031,UTC-5,705 Boulder Drive, +245,193000000000000002,193000000000100002,ABE3,ABE3,US,PA,Breinigsville,18031,UTC-5,650 Boulder Drive, +142,193000000000000003,193000000000100003,ABE4,ABE4,US,PA,Easton,18045,UTC-5,1610 Van Buren Road,126 +239,193000000000000004,193000000000100004,ABE5,ABE5,US,PA,Harrisburg,17112,UTC-5,6455 Allentown Boulevard, +133,193000000000000005,193000000000100005,ABE8,ABE8,US,NJ,Florence,08518,UTC-5,401 Independence Road 08518-2200,39 +259,193000000000000253,193000000000100253,ABE8-309,ABE8-309,US,NJ,Florence,08518-2802,UTC-5,309 Cedar Lane, +260,193000000000000254,193000000000100254,ABE8-401,ABE8-401,US,NJ,Florence,08518,UTC-5,401 Independence Road, +107,193000000000000006,193000000000100006,ABQ2,ABQ2,US,NM,LOS LUNAS,87031,UTC-7,6251 Pioneer Trail NW,76 +104,193000000000000255,193000000000100255,ABQ2-UPS,ABQ2-UPS,US,AZ,GOODYEAR,85395,UTC-7,16601 W. SELLS DRIVE, +92,193000000000000007,193000000000100007,ACY2,ACY2,US,NJ,BURLINGTON,08016,UTC-5,1101 E PEARL ST,101 +149,193000000000000008,193000000000100008,AFW1,AFW1,US,TX,FORT WORTH,76131,UTC-6,1851 NE LOOP 820, +35,193000000000000009,193000000000100009,AFW5,AFW5,US,TX,BALCH SPRINGS,75180-1636,UTC-6,12374 LAKE JUNE RD,181 +156,193000000000000010,193000000000100010,AKR1,AKR1,US,OH,Canton,44705,UTC-5,4747 Rebar Ave NE,153 +182,193000000000000011,193000000000100011,ALB1,ALB1,US,NY,Castleton,12033-9634,UTC-5,1835 US Route 9,102 +137,193000000000000012,193000000000100012,AMA1,AMA1,US,TX,Amarillo,79108,UTC-6,8590 NE 24th Avenue,132 +2,193000000000000256,193000000000100256,ATL1,ATL1,US,GA,Atlanta,30349,UTC-5,"ATL1, 6055 S Fulton Pkwy, Atlanta, GA, 30349",200 +146,193000000000000257,193000000000100257,ATL1-WALMART,ATL1-WALMART,US,GA,Atlanta,30349,UTC-5,"6055 S Fulton Pkwy,", +39,193000000000000013,193000000000100013,ATL2N,ATL2N,US,GA,Carrollton,30117,UTC-5,3101 N. Highway 27,157 +155,193000000000000014,193000000000100014,ATL3,ATL3,US,GA,Pendergrass,30567,UTC-5,445 Valentine Industrial Parkway, +210,193000000000000015,193000000000100015,ATL6,ATL6,US,GA,East Point,30344,UTC-5,4200 North Commerce, +241,193000000000000016,193000000000100016,AVP1,AVP1,US,PA,Hazle Township,18202-9361,UTC-5,550 Oak Ridge Road,70 +240,193000000000000017,193000000000100017,AVP3,AVP3,US,PA,GOULDSBORO,18424-9492,UTC-5,298 1ST AVE, +72,193000000000000258,193000000000100258,AZA4,AZA4,US,,,,,,16 +297,193000000000000018,193000000000100018,BDL1,BDL1,US,CT,Windsor,06095,UTC-5,801 Day Hill Rd., +216,193000000000000019,193000000000100019,BFI1,BFI1,US,WA,Sumner,98390,UTC-8,1800 140th Avenue E., +215,193000000000000020,193000000000100020,BFI3,BFI3,US,WA,Dupont,98327-9607,UTC-8,2700 Center Drive,34 +214,193000000000000021,193000000000100021,BFI7,BFI7,US,WA,Sumner,98390,UTC-8,1901 140th Ave E, +19,193000000000000022,193000000000100022,BJC1,BJC1,US,CO,AURORA,80019,UTC-7,6300 N Powhaton Road,186 +229,193000000000000023,193000000000100023,BNA1,BNA1,US,TN,Lebanon,37090,UTC-6,14840 Central Pike Suite 190, +139,193000000000000024,193000000000100024,BNA2,BNA2,US,TN,Lebanon,37090,UTC-6,500 Duke DR,37 +148,193000000000000025,193000000000100025,BNA3,BNA3,US,TN,Murfreesboro,37127,UTC-6,2020 Joe B Jackson Pkwy, +228,193000000000000026,193000000000100026,BNA4,BNA4,US,TN,Nashville,37217,UTC-6,50 airways Blvd, +118,193000000000000027,193000000000100027,BNA6,BNA6,US,TN,CLARKSVILLE,37040-5502,UTC-6,3875 GUTHRIE HWY,99 +251,193000000000000028,193000000000100028,BOS1,BOS1,US,NH,Nashua,03063,UTC-5,10 State St., +201,193000000000000029,193000000000100029,BOS2,BOS2,US,MA,Drive Stoughton,02072,UTC-5,1000 Tech Centre, +200,193000000000000030,193000000000100030,BOS7,BOS7,US,MA,Fall River,02720,UTC-5,1180 Innovation Way,103 +250,193000000000000031,193000000000100031,BWI1,BWI1,US,VA,Sterling,20166-2058,UTC-5,45121 Global Plaza, +249,193000000000000032,193000000000100032,BWI2,BWI2,US,MD,Baltimore,21224,UTC-5,2010 Broening Hwy, +110,193000000000000033,193000000000100033,BWI4,BWI4,US,VA,CLEARBROOK,22624,UTC-5,165 Business Blvd,150 +233,193000000000000034,193000000000100034,CAE1,CAE1,US,SC,West Columbia,29172,UTC-5,4400 12TH STREET EXT, +64,193000000000000259,193000000000100259,CEG2,CEG2,US,,,,,,79 +231,193000000000000035,193000000000100035,CHA1,CHA1,US,TN,Chattanooga,37416,UTC-5,7200 Discovery Drive, +230,193000000000000036,193000000000100036,CHA2,CHA2,US,TN,Charleston,37310,UTC-5,225 Infinity Dr NW,66 +83,193000000000000037,193000000000100037,CHO1,CHO1,US,VA,FISHERSVILLE,22939,UTC-5,32 TRADER RD,120 +199,193000000000000038,193000000000100038,CLT2,CLT2,US,NC,Charlotte,28214-8082,UTC-5,10240 Old Dowd Rd,59 +122,193000000000000039,193000000000100039,CLT3,CLT3,US,NC,CONCORD,28027,UTC-5,6500 Davidson Hwy,104 +247,193000000000000260,193000000000100260,CMH1-11999,CMH1-11999,US,OH,Etna,43062-7793,UTC-5,11999 National Road SW, +248,193000000000000040,193000000000100040,CMH2,CMH2,US,OH,Groveport,43125-9016,UTC-5,6050 Gateway Court,123 +189,193000000000000041,193000000000100041,CMH3,CMH3,US,OH,Monroe,45050-1848,UTC-5,700 GATEWAY BLVD,32 +270,193000000000000042,193000000000100042,CVG1,CVG1,US,KY,Hebron,41048,UTC-5,1155 Worldwide Blvd, +269,193000000000000043,193000000000100043,CVG2,CVG2,US,KY,Hebron,41048,UTC-5,1600 Worldwide Blvd, +268,193000000000000044,193000000000100044,CVG3,CVG3,US,KY,Hebron,41048,UTC-5,3680 Langley Dr., +267,193000000000000045,193000000000100045,CVG5,CVG5,US,KY,Hebron,41048,UTC-5,2285 Litton Lane, +143,193000000000000046,193000000000100046,DCA6,DCA6,US,MD,BALTIMORE,21219,UTC-5,6001 Bethlehem Blvd,135 +131,193000000000000047,193000000000100047,DEN2,DEN2,US,CO,Aurora,80019,UTC-7,22205 East 19th Ave,107 +128,193000000000000048,193000000000100048,DEN8,DEN8,US,CO,Aurora,80018,UTC-7,21000 E 13th Ave,73 +197,193000000000000049,193000000000100049,DET1,DET1,US,MI,Livonia,48150-1543,UTC-5,39000 Amrhein Road,118 +190,193000000000000050,193000000000100050,DET2,DET2,US,MI,Shelby Township,48317-1318,UTC-5,50500 Mound Rd,136 +151,193000000000000051,193000000000100051,DFW5S,DFW5S,US,TX,Lancaster,75146,UTC-6,"2500 E Belt Line Rd,", +227,193000000000000052,193000000000100052,DFW6,DFW6,US,TX,Coppell,75019,UTC-6,940 W Bethel Road,105 +225,193000000000000053,193000000000100053,DFW8,DFW8,US,TX,DFW Airport,75261,UTC-6,2700 Regent Blvd, +264,193000000000000054,193000000000100054,EWR4,EWR4,US,NJ,Robbinsville,08691,UTC-5,50 New Canton Way, +263,193000000000000055,193000000000100055,EWR5,EWR5,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., +262,193000000000000056,193000000000100056,EWR7,EWR7,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., +261,193000000000000057,193000000000100057,EWR9,EWR9,US,NJ,Carteret,07008,UTC-5,8003 Industrial Parkway, +125,193000000000000058,193000000000100058,FAT2,FAT2,US,CA,VISALIA,93291-1882,UTC-8,3315 N KELSEYST,43 +55,193000000000000261,193000000000100261,FEDEX,FEDEX,US,,,92337,,10767,12 +184,193000000000000059,193000000000100059,FOE1,FOE1,US,KS,KANSAS CITY,66109-3665,UTC-6,9400 LEAVENWORTH RD,117 +220,193000000000000060,193000000000100060,FTW1,FTW1,US,TX,Dallas,75241,UTC-6,33333 Lyndon B Johnson FREEWAY,24 +219,193000000000000061,193000000000100061,FTW2,FTW2,US,TX,Coppell,75019,UTC-6,2701 West Bethel Road, +97,193000000000000062,193000000000100062,FTW3,FTW3,US,TX,Fort Worth,76177-2517,UTC-6,15201 Heritage Pkwy, +129,193000000000000063,193000000000100063,FTW5,FTW5,US,TX,Forney,75126,UTC-6,1475 AKRON WAY,48 +179,193000000000000064,193000000000100064,FWA4,FWA4,US,IN,FORT WAYNE,46809-9771,UTC-5,9798 Smith Road,58 +159,193000000000000065,193000000000100065,GEG2,GEG2,US,WA,SPOKANE,99216,UTC-8,18007 E Garland Ave,108 +75,193000000000000066,193000000000100066,GEU2,GEU2,US,AZ,Glendale,85307,UTC-7,15301 West Northern Avenue,97 +86,193000000000000067,193000000000100067,GEU3,GEU3,US,AZ,BUCKEYE,85396,UTC-7,18900 W McDowell Road,81 +76,193000000000000068,193000000000100068,GEU5,GEU5,US,AZ,GOODYEAR,85395,UTC-7,4660 North Cotton Lane,100 +172,193000000000000069,193000000000100069,GSO1,GSO1,US,NC,KERNERSVILLE,27284,UTC-5,1656 OLD GREENSBORO RD,131 +232,193000000000000070,193000000000100070,GSP1,GSP1,US,SC,Spartanburg,29303,UTC-5,402 John Dodd Rd,90 +196,193000000000000071,193000000000100071,GYR2,GYR2,US,AZ,GOODYEAR,85395-7672,UTC-7,17341 W MINNEZONA AVE,7 +195,193000000000000072,193000000000100072,GYR3,GYR3,US,AZ,PHOENIX,85043-2356,UTC-7,8181 W ROOSEVELT ST,8 +65,193000000000000262,193000000000100262,HEA2,HEA2,US,,,18031,,"650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States",77 +88,193000000000000073,193000000000100073,HGR6,HGR6,US,MD,HAGERSTOWN,21740-7301,UTC-5,55 West Oak Ridge Drive,130 +95,193000000000000074,193000000000100074,HIA1,HIA1,US,PA,MIDDLETOWN,17057,UTC-5,3327 E Harrisburg Pik,148 +52,193000000000000075,193000000000100075,HLI2,HLI2,US,CA,HOLLISTER,95023,UTC-8,600 Ernie Drive,137 +224,193000000000000076,193000000000100076,HOU1,HOU1,US,TX,Humble,77338,UTC-6,8120 Humble Westfield Rd, +223,193000000000000077,193000000000100077,HOU2,HOU2,US,TX,Houston,77038-2324,UTC-6,10550 Ella Blvd., +222,193000000000000078,193000000000100078,HOU3,HOU3,US,TX,Brookshire,77423,UTC-6,31555 Highway 90 E, +67,193000000000000263,193000000000100263,HOU7,HOU7,US,,,,,,63 +162,193000000000000079,193000000000100079,HOU8,HOU8,US,TX,FRESNO,77545,UTC-6,2303 Hurricane Lane,49 +150,193000000000000080,193000000000100080,HSV1,HSV1,US,AL,Madison,35756,UTC-6,7817 Greenbrier Road,80 +134,193000000000000081,193000000000100081,HSV2,HSV2,US,AL,MADISON,35756,UTC-6,28869 Fanning Dr NW, +163,193000000000000082,193000000000100082,IAH3,IAH3,US,TX,Houston,77032,UTC-6,15525 Milner Road,57 +70,193000000000000264,193000000000100264,IAK3,IAK3,US,,,,,,44 +11,193000000000000083,193000000000100083,IAZ1,IAZ1,US,AZ,GOODYEAR,85395-4515,UTC-7,"4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States",179 +144,193000000000000084,193000000000100084,ICT2,ICT2,US,KS,PARK CITY,67219,UTC-6,7130 N BROADWAY AVE,109 +10,193000000000000085,193000000000100085,IGA3,IGA3,US,GA,MACON,31216-6427,UTC-5,"7001 Skipper Rd 31216-6427 MACON, GA - United States",197 +160,193000000000000086,193000000000100086,IGQ2,IGQ2,US,IL,University park,60484,UTC-6,23257 S. Central Avenue,110 +127,193000000000000087,193000000000100087,ILG1,ILG1,US,DE,NEW CASTLE,19720,UTC-5,780 S. DuPont Highway,35 +9,193000000000000265,193000000000100265,IMI1,IMI1,US,MI,LANSING,48917-8539,UTC-5,"6500 W Mt Hope Hwy 48917-8539 - LANSING, MI - United States",199 +18,193000000000000088,193000000000100088,IMO1,IMO1,US,MO,LIBERTY,64068-8933,UTC-6,2361 S Withers RD,178 +14,193000000000000089,193000000000100089,IMS1,IMS1,US,MS,OLIVE BRANCH,38654,UTC-6,IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US,189 +13,193000000000000090,193000000000100090,INC1,INC1,US,NC,FAYETTEVILLE,28303-2215,UTC-5,"6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States",190 +287,193000000000000266,193000000000100266,IND1-AMAZON,IND1-AMAZON,US,IN,Whitestown,46075,UTC-5,4255 Anson Blvd, +176,193000000000000267,193000000000100267,IND1-WALMART,IND1-WALMART,US,IN,Avon,46123,UTC-5,9590 AllPoints Pkwy, +96,193000000000000091,193000000000100091,IND2,IND2,US,IN,Plainfield,46168-7442,UTC-5,715 Airtech Pkwy,127 +286,193000000000000268,193000000000100268,IND2-AMAZON,IND2-AMAZON,US,IN,Plainfield,46168,UTC-5,715 Airtech Pkwy, +177,193000000000000269,193000000000100269,IND2-WALMART,IND2-WALMART,US,IN,Avon,46123,UTC-5,9360 AllPoints Pkwy, +6,193000000000000270,193000000000100270,IND3,IND3,US,IN,Mccordsville,46055,UTC-5,5756 Enterprise Dr,198 +147,193000000000000271,193000000000100271,IND3-亚马逊,IND3-亚马逊,US,IN,Pkwy Plainfield,46168,,"717 Airtech Pkwy Plainfield IN 46168,USA", +285,193000000000000272,193000000000100272,IND3-沃尔玛,IND3-沃尔玛,US,IN,Mccordsville,46055,UTC-5,"5756 Enterprise Dr, Mccordsville, IN 46055", +284,193000000000000092,193000000000100092,IND4,IND4,US,IN,Indianapolis,46231,UTC-5,710 S. Girls School Rd, +283,193000000000000093,193000000000100093,IND5,IND5,US,IN,Plainfield,46168,UTC-5,800 Perry Road,140 +282,193000000000000094,193000000000100094,IND6,IND6,US,IN,Jeffersonville,47130,UTC-5,1453 E 10th St, +181,193000000000000095,193000000000100095,IND9,IND9,US,IN,GREENWOOD,46143-7830,UTC-5,1151 S GRAHAM RD,17 +3,193000000000000273,193000000000100273,IPA1,IPA1,US,PA,OLYPHANT,18447-2140,UTC-5,1300 Corporate Way,194 +17,193000000000000096,193000000000100096,ITX3,ITX3,US,TX,AMARILLO,79108-7749,UTC-6,"ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US",187 +30,193000000000000097,193000000000100097,IUSF,IUSF,US,TX,DALLAS,75241,UTC-6,3351 Balmorhea Dr,169 +77,193000000000000098,193000000000100098,IUSJ,IUSJ,US,CA,BEAUMONT,92223,UTC-8,36900 West 4th Street,98 +82,193000000000000099,193000000000100099,IUSP,IUSP,US,CA,"HESPERIA,",92344,UTC-8,8140 Caliente Rd,83 +91,193000000000000274,193000000000100274,IUSP-AWD,IUSP-AWD,US,CA,HESPERIA,92344,UTC-8,8140 Caliente Rd,83 +78,193000000000000100,193000000000100100,IUSQ,IUSQ,US,CA,REDLANDS,92374,UTC-8,2125 W San Bernardino Ave,95 +48,193000000000000101,193000000000100101,IUSR,IUSR,US,SC,COWPENS,29330,UTC-5,1120 Mt Olive Rd,158 +47,193000000000000102,193000000000100102,IUST,IUST,US,PA,GREENCASTLE,17225,UTC-5,"3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States",149 +41,193000000000000103,193000000000100103,IUSW,IUSW,US,CA,Hesperia,92344,UTC-8,"8130 Caliente Rd 92344 - Hesperia, CA - United States",156 +29,193000000000000104,193000000000100104,IUTE,IUTE,US,AZ,GOODYEAR,85338,UTC-7,16155 W Elwood St,170 +16,193000000000000105,193000000000100105,IUTJ,IUTJ,US,CA,Fontana,92335,UTC-8,"9253 Dreamland Drive 92335 - Fontana, CA - United",177 +15,193000000000000106,193000000000100106,IUTL,IUTL,US,CA,Moreno Valley,92551,UTC-8,"24405 Krameria Ave 92551 - Moreno Valley, CA - United States",188 +12,193000000000000107,193000000000100107,IWA6,IWA6,US,WA,PASCO,99301-6161,UTC-8,"1202 S Road 40 E 99301-6161 - PASCO, WA - United States",191 +58,193000000000000275,193000000000100275,JAX3,JAX3,US,,,32210,,JAX3-32210,111 +289,193000000000000276,193000000000100276,JAX3-32210,JAX3-32210,US,FL,Jacksonville,32210-8686,UTC-5,13333 103rd Street,111 +175,193000000000000277,193000000000100277,JAX3-32221,JAX3-32221,US,FL,Jacksonville,32221,UTC-5,13333 103rd Street, +80,193000000000000108,193000000000100108,JOT1,JOT1,US,CA,SAN BERNARDINO,92408,UTC-8,1494 S WATERMAN AVE,93 +158,193000000000000109,193000000000100109,JVL1,JVL1,US,WI,BELOIT,53511,UTC-6,1255 Gateway Blvd,125 +186,193000000000000110,193000000000100110,KRB4,KRB4,US,CA,PERRIS,92571-9722,UTC-8,4501 PATTERSON AVE, +169,193000000000000111,193000000000100111,KY1,KY1,US,KY,Shepherdsville,40165,UTC-5,"120 Velocity Way.,",60 +108,193000000000000112,193000000000100112,LAN2,LAN2,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy,96 +109,193000000000000278,193000000000100278,LAN2-48917,LAN2-48917,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy, +255,193000000000000113,193000000000100113,LAS1,LAS1,US,NV,HENDERSON,89044,UTC-8,12300 Bermuda Road,5 +254,193000000000000114,193000000000100114,LAS2,LAS2,US,NV,Las Vegas,89030,UTC-8,"3837 Bay Lake Trail, Suite 111 North", +140,193000000000000115,193000000000100115,LAS6,LAS6,US,NV,Las Vegas,89115,UTC-8,4550 Nexus Way,50 +8,193000000000000279,193000000000100279,LAX1,LAX1,US,CA,Chino,91708,UTC-8,"6750 Kimball Avenue, Chino,CA 91708,USA",192 +185,193000000000000280,193000000000100280,LAX1-WALMART,LAX1-WALMART,US,CA,Chino,91708,UTC-8,6750 Kimball Avenue, +170,193000000000000116,193000000000100116,LAX2T,LAX2T,US,CA,CHINO,91708,UTC-8,6730 Kimball Ave,19 +188,193000000000000281,193000000000100281,LAX2T-WALMART,LAX2T-WALMART,US,CA,Chino,91708,UTC-8,6720 Kimball Ave, +191,193000000000000117,193000000000100117,LAX9,LAX9,US,CA,FONTANA,92337,UTC-8,11263 Oleander Ave,3 +94,193000000000000118,193000000000100118,LBE1,LBE1,US,PA,Greensburg,15605,UTC-5,165 Glen Fox Road,112 +93,193000000000000282,193000000000100282,LBE1-UPS,LBE1-UPS,US,PA,New Stanton,15672,UTC-5,165 GLENN FOX RD, +276,193000000000000119,193000000000100119,LEX1,LEX1,US,KY,Lexington,40511,UTC-5,1850 Mercer RD, +275,193000000000000120,193000000000100120,LEX2,LEX2,US,KY,Lexington,40511,UTC-5,172 Trade St., +115,193000000000000121,193000000000100121,LFT1,LFT1,US,LA,CARENCRO,70520-5976,UTC-6,3550 NE EVANGELINE TRWY,31 +299,193000000000000122,193000000000100122,LGB3,LGB3,US,CA,Eastvale,91752,UTC-8,4950 Goodman Way, +300,193000000000000123,193000000000100123,LGB4,LGB4,US,CA,Redlands,92374,UTC-8,27517 Pioneer Avenue,45 +301,193000000000000124,193000000000100124,LGB6,LGB6,US,CA,Riverside,92518-1513,UTC-8,20901 Krameria Ave,26 +302,193000000000000125,193000000000100125,LGB8,LGB8,US,CA,Rialto,92376,UTC-8,1568 N Linden Ave,2 +114,193000000000000126,193000000000100126,LIT2,LIT2,US,AR,NORTH LITTLE ROCK,72117,UTC-6,13001 HIGHWAY 70,159 +40,193000000000000127,193000000000100127,MCC1,MCC1,US,CA,RANCHO CORDOVA,95742,UTC-8,"3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States",161 +165,193000000000000128,193000000000100128,MCE1,MCE1,US,CA,TURLOCK,95380,UTC-8,FULKERTH RD,65 +279,193000000000000129,193000000000100129,MCI1,MCI1,US,KS,Lenexa,66219,UTC-6,113th St & Renner Blvd., +20,193000000000000130,193000000000100130,MCI4,MCI4,US,MO,KANSAS CITY,64163,UTC-6,MCI4 - 10501 NW 136th Street,176 +116,193000000000000283,193000000000100283,MCO1-WALMART,MCO1-WALMART,US,FL,Davenport,33897,UTC-5,5100 NORTH RIDGE TRAIL, +123,193000000000000131,193000000000100131,MCO2,MCO2,US,FL,Deltona,32725-2610,UTC-5,2600 N Normandy Blvd,106 +238,193000000000000132,193000000000100132,MDT1,MDT1,US,PA,Carlisle,17015,UTC-5,2Ames Drive,121 +136,193000000000000133,193000000000100133,MDT4,MDT4,US,PA,York,17408,UTC-5,5125 Commerce Drive,144 +208,193000000000000134,193000000000100134,MDW2,MDW2,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive,46 +119,193000000000000284,193000000000100284,MDW2-60433-3280,MDW2-60433-3280,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive, +207,193000000000000135,193000000000100135,MDW6,MDW6,US,IL,ROMEOVILLE,60446-6529,UTC-6,1125 W REMINGTON BLVD ROMEOVILLE,143 +206,193000000000000136,193000000000100136,MDW7,MDW7,US,IL,Monee,60449,UTC-6,6605 Monee Manhattan Road, +205,193000000000000137,193000000000100137,MDW8,MDW8,US,IL,Waukegan,60085,UTC-6,1750 Bridge Drive, +204,193000000000000138,193000000000100138,MDW9,MDW9,US,IL,Aurora,60502,UTC-6,2865 Duke Parkway,36 +171,193000000000000139,193000000000100139,MEM1,MEM1,US,TN,Memphis,38118,UTC-6,3292 E Holmes Rd,85 +183,193000000000000285,193000000000100285,MEM1S-WALMART,MEM1S-WALMART,US,MS,OLIVE BRANCH,38654,UTC-6,9200 Alexander Road, +126,193000000000000140,193000000000100140,MEM6,MEM6,US,MS,OLIVE BRANCH,38654,UTC-6,11505 Progress Way,51 +209,193000000000000141,193000000000100141,MGE3,MGE3,US,GA,Jefferson,30549-2010,UTC-5,808 Hog Mountain Road,133 +174,193000000000000286,193000000000100286,MIA1-33054,MIA1-33054,US,FL,Opa-Locka,33054,UTC-5,14000 NW 37th Avenue, +290,193000000000000287,193000000000100287,MIA1-33182,MIA1-33182,US,FL,Miami,33182,UTC-5,1900 NW 132nd Place Doral, +85,193000000000000142,193000000000100142,MIT2,MIT2,US,CA,SHAFTER,93263,UTC-8,5408 Express Avenue,82 +278,193000000000000143,193000000000100143,MKC4,MKC4,US,KS,Edgerton,66021-9588,UTC-6,19645 Waverly Rd,67 +277,193000000000000144,193000000000100144,MKC6,MKC6,US,KS,Kansas City,66102,UTC-6,6925 Riverview Avenue, +211,193000000000000145,193000000000100145,MKE1,MKE1,US,WI,Kenosha,53144,UTC-6,3501 120th Ave, +102,193000000000000146,193000000000100146,MQJ1,MQJ1,US,IN,GREENFIELD,46140,UTC-5,4412 W 300 RD N,88 +7,193000000000000288,193000000000100288,MQY2,MQY2,US,TN,LEBANON,37090,UTC-6,"MQY2 - 1301 Richard Petty Way 37090 - LEBANON, TN - United States",193 +198,193000000000000147,193000000000100147,MSP1,MSP1,US,MN,Shakopee,55379,UTC-6,2601 4th Avenue East, +25,193000000000000148,193000000000100148,NJ3,NJ3,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way,175 +87,193000000000000289,193000000000100289,NJ3-WALMART,NJ3-WALMART,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way, +5,193000000000000290,193000000000100290,NJ3T,NJ3T,US,NJ,Pedricktown,08067,UTC-5,7 Sorbello Rd,195 +307,193000000000000149,193000000000100149,OAK3,OAK3,US,CA,Patterson,95363-8876,UTC-8,255 Park Center Drive,41 +308,193000000000000150,193000000000100150,OAK4,OAK4,US,CA,Tracy,95304-9370,UTC-8,1555 N. Chrisman Rd, +141,193000000000000151,193000000000100151,OKC2,OKC2,US,OK,OKLAHOMA CITY,73159,UTC-6,8991 S PORTLAND AVENUE,33 +27,193000000000000152,193000000000100152,OLM1,OLM1,US,WA,OLYMPIA,98516,UTC-8,"3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States",172 +311,193000000000000153,193000000000100153,ONT2,ONT2,US,CA,San Bernardino,92408,UTC-8,1910 E Central Ave, +310,193000000000000154,193000000000100154,ONT6,ONT6,US,CA,Moreno Valley,92551-9561,UTC-8,24208 San Michele Rd, +309,193000000000000155,193000000000100155,ONT8,ONT8,US,CA,Moreno Valley,92551-9534,UTC-8,24300 Nandina Ave,1 +306,193000000000000156,193000000000100156,ONT9,ONT9,US,CA,Redlands,92374,UTC-8,2125 W. San Bernandino Ave,25 +99,193000000000000291,193000000000100291,ORD1S-WALMART,ORD1S-WALMART,US,IL,Joliet,60436,UTC-6,3501 Brandon Road, +111,193000000000000157,193000000000100157,ORD2,ORD2,US,IL,CHANNAHON,60410-8758,UTC-6,23714 W Amoco Rd,21 +203,193000000000000158,193000000000100158,ORD6,ORD6,US,IL,Wood Dale,60191,UTC-6,1250 N Mittel Blvd, +121,193000000000000159,193000000000100159,ORF2,ORF2,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD,38 +167,193000000000000292,193000000000100292,ORF2-FEDEX,ORF2-FEDEX,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD, +124,193000000000000293,193000000000100293,ORF2-UPS,ORF2-UPS,US,VA,Richmond,23297,UTC-5,9603 Coach Road, +89,193000000000000160,193000000000100160,PBI3,PBI3,US,FL,Jacksonville,32206,UTC-5,21 Jump Street,113 +135,193000000000000161,193000000000100161,PDX7,PDX7,US,OR,Salem,97317,UTC-8,4775 Depot Ct SE,68 +296,193000000000000162,193000000000100162,PHL1,PHL1,US,DE,New Castle,19720,UTC-5,1 Centerpoint Blvd., +295,193000000000000163,193000000000100163,PHL3,PHL3,US,DE,New Castle,19720,UTC-5,1600 Johnson Way, +244,193000000000000164,193000000000100164,PHL4,PHL4,US,PA,Carlisle,17015,UTC-5,21 Roadway Dr,52 +243,193000000000000165,193000000000100165,PHL5,PHL5,US,PA,Lewisberry,17339,UTC-5,500 McCarthy Dr, +28,193000000000000166,193000000000100166,PHL5S,PHL5S,US,PA,Greencastle,17225,UTC-5,"1915 Ebberts Spg Ct, Greencastle, PA 17225",171 +98,193000000000000294,193000000000100294,PHL5S-WALMART,PHL5S-WALMART,US,PA,Greencastle,17225,UTC-5,1915 Ebberts Spg Ct, +242,193000000000000167,193000000000100167,PHL6,PHL6,US,PA,Carlisle,17015,UTC-5,675 Allen Rd.,69 +294,193000000000000168,193000000000100168,PHL7,PHL7,US,DE,Middletown,19709,UTC-5,560 Merrimac Ave, +293,193000000000000169,193000000000100169,PHL8,PHL8,US,DE,Middletown,19709,UTC-5,727 N Broad St, +315,193000000000000170,193000000000100170,PHX3,PHX3,US,AZ,Phoenix,85043,UTC-7,6835 West Buckeye Road, +314,193000000000000171,193000000000100171,PHX5,PHX5,US,AZ,Goodyear,85338,UTC-7,16920 W Commerce Drive,72 +313,193000000000000172,193000000000100172,PHX6,PHX6,US,AZ,Phoenix,85043,UTC-7,4750 West Mohave St, +312,193000000000000173,193000000000100173,PHX7,PHX7,US,AZ,Phoenix,85043,UTC-7,800 N 75th Ave,74 +237,193000000000000174,193000000000100174,PIT1,PIT1,US,PA,Pittsburgh,15205,UTC-5,2250 Roswell Drive, +166,193000000000000175,193000000000100175,PIT2,PIT2,US,PA,IMPERIAL,15126-1002,UTC-5,1200 Westport Rd,78 +54,193000000000000176,193000000000100176,POC1,POC1,US,CA,FONTANA,92336-1123,UTC-8,4121 LYTLE CREEK RD,128 +46,193000000000000177,193000000000100177,POC2,POC2,US,CA,ONTARIO,91761,UTC-8,4000 South Hamner Avenue,151 +53,193000000000000178,193000000000100178,POC3,POC3,US,CA,JURUPA VALLEY,92509,UTC-8,6120 Clinker Drive,129 +57,193000000000000179,193000000000100179,PPO4,PPO4,US,IN,MERRILLVILLE,46410,UTC-6,9850 Mississippi Street,122 +101,193000000000000180,193000000000100180,PSC2,PSC2,US,WA,PASCO,99301,UTC-8,1351 S Road 40 E,75 +100,193000000000000295,193000000000100295,PSC2-UPS,PSC2-UPS,US,OR,Portland,97242,UTC-8,9930 NE Alderwood Rd, +36,193000000000000181,193000000000100181,PSP3,PSP3,US,CA,DESERT HOT SPRINGS,92240,UTC-8,64165 19th AVE,162 +79,193000000000000182,193000000000100182,QXY5,QXY5,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,92 +43,193000000000000183,193000000000100183,QXY8,QXY8,US,CA,FRESNO,93725-1962,UTC-8,3611 S NORTHPOINTE DR,154 +22,193000000000000184,193000000000100184,QZZ7,QZZ7,US,AZ,MESA,85212-9608,UTC-7,8560 E ELLIOT RD,184 +154,193000000000000185,193000000000100185,RDU2,RDU2,US,NC,Smithfield,27577,UTC-5,2150 US HWY 70 Business West,56 +103,193000000000000186,193000000000100186,RDU4,RDU4,US,NC,FAYETTEVILLE,28303,UTC-5,6309 Bragg Blvd,145 +153,193000000000000187,193000000000100187,RFD2,RFD2,US,IL,Huntley,60142,UTC-6,11500 Freeman Road,71 +24,193000000000000188,193000000000100188,RFD4,RFD4,US,IL,HUNTLEY,60142,UTC-6,"11400 Venture Court 60142-8075 - HUNTLEY, IL - United States",174 +218,193000000000000189,193000000000100189,RIC1,RIC1,US,VA,Petersburg,23803,UTC-5,5000 Commerce Way,142 +217,193000000000000190,193000000000100190,RIC2,RIC2,US,VA,Chester,23836,UTC-5,1901 Meadowville Technology Pkwy, +152,193000000000000191,193000000000100191,RMN3,RMN3,US,VA,Fredericksburg,22406,UTC-5,220 Centreport Parkway,84 +256,193000000000000192,193000000000100192,RNO1,RNO1,US,NV,Fernley,89408,UTC-8,1600 East Newlands RD, +253,193000000000000193,193000000000100193,RNO2,RNO2,US,NV,Reno,85906,UTC-8,8000 North Virginia Street, +252,193000000000000194,193000000000100194,RNO4,RNO4,US,NV,Reno,89506,UTC-8,8000 North Virginia Street, +81,193000000000000195,193000000000100195,RYY2,RYY2,US,GA,White,30184,UTC-5,"1250 Cassville White Road,",94 +226,193000000000000196,193000000000100196,SAT1,SAT1,US,TX,Schertz,78154-1461,UTC-6,6000 Enterprise Avenue,23 +157,193000000000000197,193000000000100197,SAT4,SAT4,US,TX,SAN ANTONIO,78245,UTC-6,10384 W US HIGHWAY 90,61 +23,193000000000000198,193000000000100198,SAT6,SAT6,US,TX,SAN MARCOS,78666,UTC-6,"1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States",183 +106,193000000000000199,193000000000100199,SAV3,SAV3,US,GA,Macon,31216-6427,UTC-5,7001 Skipper Rd,124 +193,193000000000000200,193000000000100200,SBD1,SBD1,US,CA,BLOOMINGTON,92316-3819,UTC-8,3388 S Cactus Ave,4 +187,193000000000000201,193000000000100201,SBD2,SBD2,US,CA,SAN BERNARDINO,92408-2805,UTC-8,1494 S WATERMAN AVE,15 +105,193000000000000202,193000000000100202,SCK1,SCK1,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,20 +68,193000000000000203,193000000000100203,SCK3,SCK3,US,CA,MANTECA,95336,UTC-8,3565 N AIRPORT WAY MANTECA,54 +194,193000000000000204,193000000000100204,SCK4,SCK4,US,CA,STOCKTON,95215-8354,UTC-8,6001 S AUSTIN RD,10 +112,193000000000000205,193000000000100205,SCK8,SCK8,US,CA,OAKLEY,94561-2677,UTC-8,4700 WILBUR AVE,141 +274,193000000000000206,193000000000100206,SDF1,SDF1,US,KY,Campbellsville,42718,UTC-5,1050 South Columbia AVE, +273,193000000000000207,193000000000100207,SDF2,SDF2,US,KY,Watterson Park,40218,UTC-5,4360 Robards Ln, +272,193000000000000208,193000000000100208,SDF4,SDF4,US,KY,Shepherdsville,40165,UTC-5,376 Zappos.com Blvd, +271,193000000000000209,193000000000100209,SDF6,SDF6,US,KY,Shepherdsville,40165,UTC-5,271 Omega Pkwy, +266,193000000000000210,193000000000100210,SDF7,SDF7,US,KY,Shepherdsville,40165,UTC-5,300 Omicron Court, +281,193000000000000211,193000000000100211,SDF8,SDF8,US,IN,Jeffersonville,47130,UTC-5,900 Patrol Rd, +265,193000000000000212,193000000000100212,SDF9,SDF9,US,KY,Shepherdsville,40165,UTC-5,100 W. Thomas P. Echols Lane, +213,193000000000000213,193000000000100213,SEA6,SEA6,US,WA,South Seattle,98144,UTC-8,2646 Rainier Ave., +212,193000000000000214,193000000000100214,SEA8,SEA8,US,WA,Northeast Bellevue,98005,UTC-8,1475 124th Avenue, +303,193000000000000215,193000000000100215,SJC7,SJC7,US,CA,Tracy,95377-8906,UTC-8,188 Mountain House Parkway,30 +4,193000000000000296,193000000000100296,SLC1N,SLC1N,US,UT,Salt Lake City,84116,UTC-7,990 N 6550 W,196 +161,193000000000000216,193000000000100216,SLC2,SLC2,US,UT,West Jordan,84081,UTC-7,7148 W. Old Bingham Hwy,86 +21,193000000000000217,193000000000100217,SLC3,SLC3,US,UT,SALT LAKE CITY,84116-4413,UTC-7,355 N JOHN GLENN RD,185 +298,193000000000000297,193000000000100297,SMF1-AMAZON,SMF1-AMAZON,US,CA,Sacramento,95835-9505,UTC-8,4900 W Elkhorn Blvd, +173,193000000000000298,193000000000100298,SMF1-WALMART,SMF1-WALMART,US,CA,Sacramento,95837,UTC-8,7000 Powerline Rd, +192,193000000000000218,193000000000100218,SMF3,SMF3,US,CA,Stockton,95206-8202,UTC-8,3923 S B ST,9 +113,193000000000000219,193000000000100219,SMF6,SMF6,US,CA,SACRAMENTO,95837-9109,UTC-8,4930 ALLBAUGH DR,28 +69,193000000000000299,193000000000100299,SMF7,SMF7,US,,,,,,53 +304,193000000000000220,193000000000100220,SNA4,SNA4,US,CA,Rialto,92376-3009,UTC-8,2496 W Walnut Ave,18 +117,193000000000000221,193000000000100221,STL3,STL3,US,MO,REPUBLIC,65738-2779,UTC-6,3200 E SAWYER RD,152 +202,193000000000000222,193000000000100222,STL4,STL4,US,IL,Edwardsville,62025-2815,UTC-6,3050 GATEWAY COMMERCE CENTER DR S,115 +66,193000000000000300,193000000000100300,STL6,STL6,US,,,,,,64 +130,193000000000000223,193000000000100223,SWF1,SWF1,US,NY,ROCK TAVERN,12575,UTC-5,635 INTERNATIONAL BLVD,134 +84,193000000000000224,193000000000100224,SWF2,SWF2,US,NY,Hopewell Junction,12533,UTC-5,76 Patriot Way Hopewell Junction,62 +145,193000000000000301,193000000000100301,SWF2-10710,SWF2-10710,US,NY,Yonkers,10710,UTC-5,557 Tuckahoe Road, +168,193000000000000302,193000000000100302,SWF2-12533,SWF2-12533,US,NY,Hopewell Junction,12533-6159,UTC-5,76 Patriot Way, +49,193000000000000225,193000000000100225,TCY1,TCY1,US,CA,STOCKTON,95206,UTC-8,2690 East Arch Airport Road,139 +51,193000000000000226,193000000000100226,TCY2,TCY2,US,CA,STOCKTON,95215,UTC-8,6201 Newcastle Rd,138 +258,193000000000000227,193000000000100227,TEB3,TEB3,US,NJ,Logan Township,08085,UTC-5,2651 Oldmans Creek Rd,47 +138,193000000000000228,193000000000100228,TEB4,TEB4,US,NJ,PENNS GROVE,08069,UTC-5,747 COURSES LANDING RD,55 +257,193000000000000229,193000000000100229,TEB6,TEB6,US,NJ,Cranbury,08512,UTC-5,22 Hightstown Cranbury State Road,114 +164,193000000000000230,193000000000100230,TEB9,TEB9,US,NJ,Somerset,08873,UTC-5,601 Randolph Road,40 +73,193000000000000231,193000000000100231,TMB8,TMB8,US,FL,HOMESTEAD,33032-8597,UTC-5,27505 SW 132nd AVE,116 +74,193000000000000303,193000000000100303,TMB8-UPS,TMB8-UPS,US,FL,Miami Gardens,33055,UTC-5,8675309 Jenny Lane, +26,193000000000000232,193000000000100232,TOL3,TOL3,US,OH,PERRYSBURG,43551,UTC-5,"9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States",173 +292,193000000000000233,193000000000100233,TPA1,TPA1,US,FL,Ruskin,33570,UTC-5,3350 Laurel Ridge Ave., +291,193000000000000234,193000000000100234,TPA2,TPA2,US,FL,Lakeland,33811,UTC-5,1760 County Line Rd,22 +132,193000000000000235,193000000000100235,TPA3,TPA3,US,FL,Auburndale,33823,UTC-5,676 C. Fred Jones Boulevard,146 +280,193000000000000236,193000000000100236,TUL1,TUL1,US,KS,Coffeyville,67337,UTC-6,2654 North Highway 169, +56,193000000000000304,193000000000100304,UPS,UPS,US,,,92337,,10767,11 +180,193000000000000237,193000000000100237,VGT2,VGT2,US,NV,LAS VEGAS,89115-2013,UTC-8,6401 Howdy Wells Ave,6 +120,193000000000000305,193000000000100305,VGT2-FEDEX,VGT2-FEDEX,US,NV,LAS VEGAS,89115-201,UTC-8,6401 Howdy Wells Ave, +178,193000000000000306,193000000000100306,VGT2-UPS,VGT2-UPS,US,CA,March Air Reserve Base,92518,UTC-8,20701 Krameria Ave, +236,193000000000000238,193000000000100238,VUBA,VUBA,US,PA,Scranton,18512,UTC-5,1000 Keystone Industrial Park, +235,193000000000000239,193000000000100239,VUGA,VUGA,US,PA,Pittston,18643,UTC-5,508 Delaware Avenue West, +90,193000000000000240,193000000000100240,WBW2,WBW2,US,PA,OLYPHANT,18447,UTC-5,1300 Corporate Way,119 +44,193000000000000241,193000000000100241,XAV3,XAV3,US,GA,PORT WENTWORTH,31407,UTC-5,"XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States",180 +61,193000000000000307,193000000000100307,XBN3,XBN3,US,,,,,,91 +63,193000000000000308,193000000000100308,XCH2,XCH2,US,,,,,,87 +45,193000000000000242,193000000000100242,XEW5,XEW5,US,NJ,Cinnaminson,08077-2015,UTC-5,995 Taylors Ln 08077-2015,163 +33,193000000000000243,193000000000100243,XIN5,XIN5,US,IN,Avon,46123-1700,UTC-5,8838 E COUNTY ROAD 100 S,182 +42,193000000000000244,193000000000100244,XLX1,XLX1,US,MD,HAGERSTOWN,21740,UTC-5,"1115 Wesel Blvd HAGERSTOWN,MD 21740,USA",160 +31,193000000000000245,193000000000100245,XLX6,XLX6,US,NC,PINEVILLE,28134,UTC-5,"12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA",166 +71,193000000000000246,193000000000100246,XLX7,XLX7,US,CA,VICTORVILLE,92394,UTC-8,"18580 Gateway Dr,Victorville,CA 92394,USA",27 +62,193000000000000309,193000000000100309,XMI3,XMI3,US,,,,,,89 +34,193000000000000247,193000000000100247,XPB2,XPB2,US,FL,APOPKA,32712,UTC-5,"XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States",165 +32,193000000000000248,193000000000100248,XRI3,XRI3,US,VA,Richmond,23234,UTC-5,"4949 Commerce Rd, Richmond, VA 23234",167 +221,193000000000000249,193000000000100249,XUSB,XUSB,US,TX,Fort Worth,76155,UTC-6,14900 Frye Rd., +234,193000000000000250,193000000000100250,XUSC,XUSC,US,PA,Carlisle,17013,UTC-5,40 Logistics Drive, +305,193000000000000251,193000000000100251,XUSD,XUSD,US,CA,Stockton,95206,UTC-8,1909 Zephyr St, +288,193000000000000252,193000000000100252,XUSE,XUSE,US,IN,Whitestown,46075,UTC-5,5100 S Indianapolis Rd, +60,193000000000000310,193000000000100310,洛杉矶仓一件代发,洛杉矶仓一件代发,US,CA,RANCHO CUCAMONGA,91730,UTC-8,10767 7TH STREET,CADF +50,193000000000000311,193000000000100311,海外仓打托,海外仓打托,US,,,91730,,91730,13 +59,193000000000000312,193000000000100312,私人仓,私人仓,US,,,私人仓,,私人仓,14 +38,193000000000000313,193000000000100313,纽约仓,纽约仓,US,NJ,New Jersey,08817,UTC-5,"13、14号门75 Carter Dr Edison,", +37,193000000000000314,193000000000100314,纽约仓一件代发,纽约仓一件代发,US,NJ,New Jersey,08817,UTC-5,"Door13-14,75 Carter Dr Edison,",NJDF diff --git a/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv b/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv index 8b7ab239a..1e3f5df45 100644 --- a/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv +++ b/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv @@ -1,55 +1 @@ source_row,warehouse_code,reject_reason,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id -29,纽约仓一件代发,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"Door13-14,75 Carter Dr Edison,",NJDF -30,纽约仓,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"13、14号门75 Carter Dr Edison,", -42,海外仓打托,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,91730,,91730,13 -47,FEDEX,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,12 -48,UPS,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,11 -50,JAX3,missing_city|missing_state,US,,,32210,,JAX3-32210,111 -51,私人仓,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,私人仓,,私人仓,14 -52,洛杉矶仓一件代发,invalid_fba_code|non_fba_business_node,US,CA,RANCHO CUCAMONGA,91730,UTC-8,10767 7TH STREET,CADF -53,XBN3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,91 -54,XMI3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,89 -55,XCH2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,87 -56,CEG2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,79 -57,HEA2,missing_city|missing_state,US,,,18031,,"650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States",77 -58,STL6,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,64 -59,HOU7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,63 -61,SMF7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,53 -62,IAK3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,44 -64,AZA4,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,16 -66,TMB8-UPS,invalid_fba_code|non_fba_business_node,US,FL,Miami Gardens,33055,UTC-5,8675309 Jenny Lane, -79,NJ3-WALMART,invalid_fba_code|non_fba_business_node,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way, -83,IUSP-AWD,invalid_fba_code,US,CA,HESPERIA,92344,UTC-8,8140 Caliente Rd,83 -85,LBE1-UPS,invalid_fba_code|non_fba_business_node,US,PA,New Stanton,15672,UTC-5,165 GLENN FOX RD, -90,PHL5S-WALMART,invalid_fba_code|non_fba_business_node,US,PA,Greencastle,17225,UTC-5,1915 Ebberts Spg Ct, -91,ORD1S-WALMART,invalid_fba_code|non_fba_business_node,US,IL,Joliet,60436,UTC-6,3501 Brandon Road, -92,PSC2-UPS,invalid_fba_code|non_fba_business_node,US,OR,Portland,97242,UTC-8,9930 NE Alderwood Rd, -96,ABQ2-UPS,invalid_fba_code|non_fba_business_node,US,AZ,GOODYEAR,85395,UTC-7,16601 W. SELLS DRIVE, -101,LAN2-48917,invalid_fba_code,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy, -108,MCO1-WALMART,invalid_fba_code|non_fba_business_node,US,FL,Davenport,33897,UTC-5,5100 NORTH RIDGE TRAIL, -111,MDW2-60433-3280,invalid_fba_code,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive, -112,VGT2-FEDEX,invalid_fba_code|non_fba_business_node,US,NV,LAS VEGAS,89115-201,UTC-8,6401 Howdy Wells Ave, -116,ORF2-UPS,invalid_fba_code|non_fba_business_node,US,VA,Richmond,23297,UTC-5,9603 Coach Road, -137,SWF2-10710,invalid_fba_code,US,NY,Yonkers,10710,UTC-5,557 Tuckahoe Road, -138,ATL1-WALMART,invalid_fba_code|non_fba_business_node,US,GA,Atlanta,30349,UTC-5,"6055 S Fulton Pkwy,", -139,IND3-亚马逊,invalid_fba_code|timezone_pending_confirmation,US,IN,Pkwy Plainfield,46168,,"717 Airtech Pkwy Plainfield IN 46168,USA", -159,ORF2-FEDEX,invalid_fba_code|non_fba_business_node,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD, -160,SWF2-12533,invalid_fba_code,US,NY,Hopewell Junction,12533-6159,UTC-5,76 Patriot Way, -165,SMF1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Sacramento,95837,UTC-8,7000 Powerline Rd, -166,MIA1-33054,invalid_fba_code,US,FL,Opa-Locka,33054,UTC-5,14000 NW 37th Avenue, -167,JAX3-32221,invalid_fba_code,US,FL,Jacksonville,32221,UTC-5,13333 103rd Street, -168,IND1-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9590 AllPoints Pkwy, -169,IND2-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9360 AllPoints Pkwy, -170,VGT2-UPS,invalid_fba_code|non_fba_business_node,US,CA,March Air Reserve Base,92518,UTC-8,20701 Krameria Ave, -175,MEM1S-WALMART,invalid_fba_code|non_fba_business_node,US,MS,OLIVE BRANCH,38654,UTC-6,9200 Alexander Road, -177,LAX1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6750 Kimball Avenue, -180,LAX2T-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6720 Kimball Ave, -239,CMH1-11999,invalid_fba_code,US,OH,Etna,43062-7793,UTC-5,11999 National Road SW, -251,ABE8-309,invalid_fba_code,US,NJ,Florence,08518-2802,UTC-5,309 Cedar Lane, -252,ABE8-401,invalid_fba_code,US,NJ,Florence,08518,UTC-5,401 Independence Road, -277,IND3-沃尔玛,invalid_fba_code,US,IN,Mccordsville,46055,UTC-5,"5756 Enterprise Dr, Mccordsville, IN 46055", -278,IND2-AMAZON,invalid_fba_code,US,IN,Plainfield,46168,UTC-5,715 Airtech Pkwy, -279,IND1-AMAZON,invalid_fba_code,US,IN,Whitestown,46075,UTC-5,4255 Anson Blvd, -281,JAX3-32210,invalid_fba_code,US,FL,Jacksonville,32210-8686,UTC-5,13333 103rd Street,111 -282,MIA1-33182,invalid_fba_code,US,FL,Miami,33182,UTC-5,1900 NW 132nd Place Doral, -290,SMF1-AMAZON,invalid_fba_code,US,CA,Sacramento,95835-9505,UTC-8,4900 W Elkhorn Blvd, diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.md b/warehouse/src/main/resources/db/warehouse-public-master-data.md index 9bc06831e..8f694134e 100644 --- a/warehouse/src/main/resources/db/warehouse-public-master-data.md +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.md @@ -11,13 +11,13 @@ ## 清洗规则 -- 读取 `C:\Users\BY-OY\Downloads\FBA地址表.xls`,原始文件只读。 +- 读取用户提供的 `FBA地址表.xls`,原始文件只读。 - 对核心字段去首尾空格。 -- FBA 编码转大写,接受 `^[A-Z0-9]{3,5}$`。 +- FBA 编码去首尾空格并转大写,全部保留,包括带渠道、邮编后缀和中文业务名称的编码。 - 邮编按文本保存,保留 `31216-6427` 这类后缀。 -- 城市、州、详细地址、邮编任一缺失则拒绝。 -- 带 `UPS`、`FEDEX`、`WALMART`、`一件代发`、`私人仓`、`纽约仓`、`洛杉矶仓`、`海外仓打托` 等非 FBA 业务节点语义的记录拒绝。 -- 多时区州必须按城市映射时区;无法可靠判断时列入拒绝清单的 `timezone_pending_confirmation`,不使用州级默认值。 +- 内部编号按文本保存,允许数字或字母,可为空、可重复;重复值代表来源表中的同组映射,不强制改写。 +- 城市、州、详细地址、邮编或时区缺失时保留空值,等待后续业务维护,不因此排除仓库。 +- `UPS`、`FEDEX`、`WALMART`、`一件代发`、`私人仓`、`纽约仓`、`洛杉矶仓`、`海外仓打托` 等来源记录全部保留。 - 数据库存储固定 UTC 偏移量(如 `UTC+8`、`UTC-8`),当前仅作为维护和展示字段使用。 ## 固定仓规则 @@ -38,8 +38,8 @@ ## 当前结果 -- Excel 数据行:306。 -- 接受:252。 -- 拒绝:54。 -- 时区待确认:1,且该行同时存在无效编码和异常城市文本。 +- Excel 数据行:314。 +- 接受:314。 +- 拒绝:0。 +- 其中 15 条缺城市或州,9 条缺详细地址或邮编,116 条缺内部编号;均按来源原值保留。 - 固定仓:4,分别为义乌、东莞、洛杉矶、纽约。 diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.sql b/warehouse/src/main/resources/db/warehouse-public-master-data.sql index e14c622a6..7751b86ca 100644 --- a/warehouse/src/main/resources/db/warehouse-public-master-data.sql +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.sql @@ -2,8 +2,8 @@ SET NAMES utf8mb4; USE baiyue_warehouse; -- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。 --- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。 --- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 +-- 来源:用户提供的 FBA地址表.xls,只读整理后生成。 +-- 完整保留 314 条,拒绝 0 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 -- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。 -- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。 @@ -190,6 +190,7 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( address_id_seed BIGINT UNSIGNED NOT NULL, warehouse_code VARCHAR(50) NOT NULL, warehouse_name VARCHAR(120) NOT NULL, + internal_code VARCHAR(16) NULL, state_province VARCHAR(100) NOT NULL, city VARCHAR(100) NOT NULL, postal_code VARCHAR(20) NOT NULL, @@ -202,281 +203,339 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; INSERT INTO tmp_wh_fba_public_master_data - (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row) + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,internal_code,state_province,city,postal_code,timezone,address_line1,source_row) VALUES -(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238), -(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237), -(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134), -(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231), -(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125), -(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99), -(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84), -(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141), -(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27), -(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148), -(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174), -(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129), -(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31), -(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147), -(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202), -(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233), -(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232), -(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289), -(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208), -(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207), -(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206), -(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11), -(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221), -(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131), -(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140), -(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220), -(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110), -(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243), -(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193), -(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192), -(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242), -(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241), -(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102), -(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225), -(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223), -(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222), -(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75), -(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191), -(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114), -(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240), -(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181), -(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262), -(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261), -(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260), -(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259), -(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135), -(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123), -(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120), -(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189), -(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182), -(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143), -(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219), -(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217), -(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256), -(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255), -(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254), -(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253), -(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117), -(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176), -(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212), -(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211), -(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89), -(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121), -(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171), -(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151), -(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67), -(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78), -(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68), -(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164), -(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224), -(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188), -(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187), -(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80), -(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87), -(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44), -(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216), -(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215), -(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214), -(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154), -(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142), -(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126), -(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155), -(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3), -(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136), -(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2), -(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152), -(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119), -(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10), -(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6), -(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5), -(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88), -(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276), -(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275), -(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274), -(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173), -(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9), -(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22), -(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69), -(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74), -(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70), -(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40), -(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39), -(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33), -(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21), -(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8), -(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7), -(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4), -(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72), -(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150), -(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178), -(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161), -(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100), -(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247), -(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246), -(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132), -(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162), -(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183), -(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86), -(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268), -(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267), -(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107), -(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291), -(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292), -(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293), -(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294), -(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106), -(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32), -(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157), -(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271), -(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12), -(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115), -(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230), -(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128), -(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200), -(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199), -(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198), -(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197), -(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196), -(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163), -(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118), -(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201), -(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77), -(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270), -(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269), -(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203), -(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94), -(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190), -(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17), -(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299), -(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300), -(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133), -(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19), -(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303), -(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302), -(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301), -(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298), -(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103), -(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195), -(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113), -(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81), -(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127), -(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288), -(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287), -(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236), -(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235), -(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20), -(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234), -(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286), -(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285), -(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307), -(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306), -(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305), -(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304), -(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229), -(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158), -(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46), -(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38), -(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45), -(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49), -(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93), -(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28), -(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71), -(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35), -(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14), -(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146), -(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95), -(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145), -(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16), -(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210), -(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209), -(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144), -(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248), -(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245), -(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244), -(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73), -(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218), -(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149), -(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15), -(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98), -(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185), -(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179), -(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97), -(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60), -(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186), -(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104), -(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266), -(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265), -(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264), -(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263), -(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258), -(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273), -(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257), -(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205), -(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204), -(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295), -(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153), -(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13), -(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184), -(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105), -(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296), -(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109), -(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194), -(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122), -(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76), -(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41), -(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43), -(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250), -(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130), -(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249), -(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156), -(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65), -(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18), -(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284), -(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283), -(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124), -(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272), -(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172), -(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228), -(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227), -(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82), -(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36), -(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37), -(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25), -(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34), -(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23), -(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63), -(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26), -(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24), -(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213), -(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226), -(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297), -(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280); +(193000000000000001,193000000000100001,'ABE2','ABE2',NULL,'PA','Breinigsville','18031','UTC-5','705 Boulder Drive',246), +(193000000000000002,193000000000100002,'ABE3','ABE3',NULL,'PA','Breinigsville','18031','UTC-5','650 Boulder Drive',245), +(193000000000000003,193000000000100003,'ABE4','ABE4','126','PA','Easton','18045','UTC-5','1610 Van Buren Road',142), +(193000000000000004,193000000000100004,'ABE5','ABE5',NULL,'PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',239), +(193000000000000005,193000000000100005,'ABE8','ABE8','39','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',133), +(193000000000000253,193000000000100253,'ABE8-309','ABE8-309',NULL,'NJ','Florence','08518-2802','UTC-5','309 Cedar Lane',259), +(193000000000000254,193000000000100254,'ABE8-401','ABE8-401',NULL,'NJ','Florence','08518','UTC-5','401 Independence Road',260), +(193000000000000006,193000000000100006,'ABQ2','ABQ2','76','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',107), +(193000000000000255,193000000000100255,'ABQ2-UPS','ABQ2-UPS',NULL,'AZ','GOODYEAR','85395','UTC-7','16601 W. SELLS DRIVE',104), +(193000000000000007,193000000000100007,'ACY2','ACY2','101','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',92), +(193000000000000008,193000000000100008,'AFW1','AFW1',NULL,'TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',149), +(193000000000000009,193000000000100009,'AFW5','AFW5','181','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',35), +(193000000000000010,193000000000100010,'AKR1','AKR1','153','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',156), +(193000000000000011,193000000000100011,'ALB1','ALB1','102','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',182), +(193000000000000012,193000000000100012,'AMA1','AMA1','132','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',137), +(193000000000000256,193000000000100256,'ATL1','ATL1','200','GA','Atlanta','30349','UTC-5','ATL1, 6055 S Fulton Pkwy, Atlanta, GA, 30349',2), +(193000000000000257,193000000000100257,'ATL1-WALMART','ATL1-WALMART',NULL,'GA','Atlanta','30349','UTC-5','6055 S Fulton Pkwy,',146), +(193000000000000013,193000000000100013,'ATL2N','ATL2N','157','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',39), +(193000000000000014,193000000000100014,'ATL3','ATL3',NULL,'GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',155), +(193000000000000015,193000000000100015,'ATL6','ATL6',NULL,'GA','East Point','30344','UTC-5','4200 North Commerce',210), +(193000000000000016,193000000000100016,'AVP1','AVP1','70','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',241), +(193000000000000017,193000000000100017,'AVP3','AVP3',NULL,'PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',240), +(193000000000000258,193000000000100258,'AZA4','AZA4','16','','','','','',72), +(193000000000000018,193000000000100018,'BDL1','BDL1',NULL,'CT','Windsor','06095','UTC-5','801 Day Hill Rd.',297), +(193000000000000019,193000000000100019,'BFI1','BFI1',NULL,'WA','Sumner','98390','UTC-8','1800 140th Avenue E.',216), +(193000000000000020,193000000000100020,'BFI3','BFI3','34','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',215), +(193000000000000021,193000000000100021,'BFI7','BFI7',NULL,'WA','Sumner','98390','UTC-8','1901 140th Ave E',214), +(193000000000000022,193000000000100022,'BJC1','BJC1','186','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',19), +(193000000000000023,193000000000100023,'BNA1','BNA1',NULL,'TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',229), +(193000000000000024,193000000000100024,'BNA2','BNA2','37','TN','Lebanon','37090','UTC-6','500 Duke DR',139), +(193000000000000025,193000000000100025,'BNA3','BNA3',NULL,'TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',148), +(193000000000000026,193000000000100026,'BNA4','BNA4',NULL,'TN','Nashville','37217','UTC-6','50 airways Blvd',228), +(193000000000000027,193000000000100027,'BNA6','BNA6','99','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',118), +(193000000000000028,193000000000100028,'BOS1','BOS1',NULL,'NH','Nashua','03063','UTC-5','10 State St.',251), +(193000000000000029,193000000000100029,'BOS2','BOS2',NULL,'MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',201), +(193000000000000030,193000000000100030,'BOS7','BOS7','103','MA','Fall River','02720','UTC-5','1180 Innovation Way',200), +(193000000000000031,193000000000100031,'BWI1','BWI1',NULL,'VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',250), +(193000000000000032,193000000000100032,'BWI2','BWI2',NULL,'MD','Baltimore','21224','UTC-5','2010 Broening Hwy',249), +(193000000000000033,193000000000100033,'BWI4','BWI4','150','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',110), +(193000000000000034,193000000000100034,'CAE1','CAE1',NULL,'SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',233), +(193000000000000259,193000000000100259,'CEG2','CEG2','79','','','','','',64), +(193000000000000035,193000000000100035,'CHA1','CHA1',NULL,'TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',231), +(193000000000000036,193000000000100036,'CHA2','CHA2','66','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',230), +(193000000000000037,193000000000100037,'CHO1','CHO1','120','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',83), +(193000000000000038,193000000000100038,'CLT2','CLT2','59','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',199), +(193000000000000039,193000000000100039,'CLT3','CLT3','104','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',122), +(193000000000000260,193000000000100260,'CMH1-11999','CMH1-11999',NULL,'OH','Etna','43062-7793','UTC-5','11999 National Road SW',247), +(193000000000000040,193000000000100040,'CMH2','CMH2','123','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',248), +(193000000000000041,193000000000100041,'CMH3','CMH3','32','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',189), +(193000000000000042,193000000000100042,'CVG1','CVG1',NULL,'KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',270), +(193000000000000043,193000000000100043,'CVG2','CVG2',NULL,'KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',269), +(193000000000000044,193000000000100044,'CVG3','CVG3',NULL,'KY','Hebron','41048','UTC-5','3680 Langley Dr.',268), +(193000000000000045,193000000000100045,'CVG5','CVG5',NULL,'KY','Hebron','41048','UTC-5','2285 Litton Lane',267), +(193000000000000046,193000000000100046,'DCA6','DCA6','135','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',143), +(193000000000000047,193000000000100047,'DEN2','DEN2','107','CO','Aurora','80019','UTC-7','22205 East 19th Ave',131), +(193000000000000048,193000000000100048,'DEN8','DEN8','73','CO','Aurora','80018','UTC-7','21000 E 13th Ave',128), +(193000000000000049,193000000000100049,'DET1','DET1','118','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',197), +(193000000000000050,193000000000100050,'DET2','DET2','136','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',190), +(193000000000000051,193000000000100051,'DFW5S','DFW5S',NULL,'TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',151), +(193000000000000052,193000000000100052,'DFW6','DFW6','105','TX','Coppell','75019','UTC-6','940 W Bethel Road',227), +(193000000000000053,193000000000100053,'DFW8','DFW8',NULL,'TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',225), +(193000000000000054,193000000000100054,'EWR4','EWR4',NULL,'NJ','Robbinsville','08691','UTC-5','50 New Canton Way',264), +(193000000000000055,193000000000100055,'EWR5','EWR5',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',263), +(193000000000000056,193000000000100056,'EWR7','EWR7',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',262), +(193000000000000057,193000000000100057,'EWR9','EWR9',NULL,'NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',261), +(193000000000000058,193000000000100058,'FAT2','FAT2','43','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',125), +(193000000000000261,193000000000100261,'FEDEX','FEDEX','12','','','92337','','10767',55), +(193000000000000059,193000000000100059,'FOE1','FOE1','117','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',184), +(193000000000000060,193000000000100060,'FTW1','FTW1','24','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',220), +(193000000000000061,193000000000100061,'FTW2','FTW2',NULL,'TX','Coppell','75019','UTC-6','2701 West Bethel Road',219), +(193000000000000062,193000000000100062,'FTW3','FTW3',NULL,'TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',97), +(193000000000000063,193000000000100063,'FTW5','FTW5','48','TX','Forney','75126','UTC-6','1475 AKRON WAY',129), +(193000000000000064,193000000000100064,'FWA4','FWA4','58','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',179), +(193000000000000065,193000000000100065,'GEG2','GEG2','108','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',159), +(193000000000000066,193000000000100066,'GEU2','GEU2','97','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',75), +(193000000000000067,193000000000100067,'GEU3','GEU3','81','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',86), +(193000000000000068,193000000000100068,'GEU5','GEU5','100','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',76), +(193000000000000069,193000000000100069,'GSO1','GSO1','131','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',172), +(193000000000000070,193000000000100070,'GSP1','GSP1','90','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',232), +(193000000000000071,193000000000100071,'GYR2','GYR2','7','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',196), +(193000000000000072,193000000000100072,'GYR3','GYR3','8','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',195), +(193000000000000262,193000000000100262,'HEA2','HEA2','77','','','18031','','650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States',65), +(193000000000000073,193000000000100073,'HGR6','HGR6','130','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',88), +(193000000000000074,193000000000100074,'HIA1','HIA1','148','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',95), +(193000000000000075,193000000000100075,'HLI2','HLI2','137','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',52), +(193000000000000076,193000000000100076,'HOU1','HOU1',NULL,'TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',224), +(193000000000000077,193000000000100077,'HOU2','HOU2',NULL,'TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',223), +(193000000000000078,193000000000100078,'HOU3','HOU3',NULL,'TX','Brookshire','77423','UTC-6','31555 Highway 90 E',222), +(193000000000000263,193000000000100263,'HOU7','HOU7','63','','','','','',67), +(193000000000000079,193000000000100079,'HOU8','HOU8','49','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',162), +(193000000000000080,193000000000100080,'HSV1','HSV1','80','AL','Madison','35756','UTC-6','7817 Greenbrier Road',150), +(193000000000000081,193000000000100081,'HSV2','HSV2',NULL,'AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',134), +(193000000000000082,193000000000100082,'IAH3','IAH3','57','TX','Houston','77032','UTC-6','15525 Milner Road',163), +(193000000000000264,193000000000100264,'IAK3','IAK3','44','','','','','',70), +(193000000000000083,193000000000100083,'IAZ1','IAZ1','179','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',11), +(193000000000000084,193000000000100084,'ICT2','ICT2','109','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',144), +(193000000000000085,193000000000100085,'IGA3','IGA3','197','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',10), +(193000000000000086,193000000000100086,'IGQ2','IGQ2','110','IL','University park','60484','UTC-6','23257 S. Central Avenue',160), +(193000000000000087,193000000000100087,'ILG1','ILG1','35','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',127), +(193000000000000265,193000000000100265,'IMI1','IMI1','199','MI','LANSING','48917-8539','UTC-5','6500 W Mt Hope Hwy 48917-8539 - LANSING, MI - United States',9), +(193000000000000088,193000000000100088,'IMO1','IMO1','178','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',18), +(193000000000000089,193000000000100089,'IMS1','IMS1','189','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',14), +(193000000000000090,193000000000100090,'INC1','INC1','190','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',13), +(193000000000000266,193000000000100266,'IND1-AMAZON','IND1-AMAZON',NULL,'IN','Whitestown','46075','UTC-5','4255 Anson Blvd',287), +(193000000000000267,193000000000100267,'IND1-WALMART','IND1-WALMART',NULL,'IN','Avon','46123','UTC-5','9590 AllPoints Pkwy',176), +(193000000000000091,193000000000100091,'IND2','IND2','127','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',96), +(193000000000000268,193000000000100268,'IND2-AMAZON','IND2-AMAZON',NULL,'IN','Plainfield','46168','UTC-5','715 Airtech Pkwy',286), +(193000000000000269,193000000000100269,'IND2-WALMART','IND2-WALMART',NULL,'IN','Avon','46123','UTC-5','9360 AllPoints Pkwy',177), +(193000000000000270,193000000000100270,'IND3','IND3','198','IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr',6), +(193000000000000271,193000000000100271,'IND3-亚马逊','IND3-亚马逊',NULL,'IN','Pkwy Plainfield','46168','','717 Airtech Pkwy Plainfield IN 46168,USA',147), +(193000000000000272,193000000000100272,'IND3-沃尔玛','IND3-沃尔玛',NULL,'IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr, Mccordsville, IN 46055',285), +(193000000000000092,193000000000100092,'IND4','IND4',NULL,'IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',284), +(193000000000000093,193000000000100093,'IND5','IND5','140','IN','Plainfield','46168','UTC-5','800 Perry Road',283), +(193000000000000094,193000000000100094,'IND6','IND6',NULL,'IN','Jeffersonville','47130','UTC-5','1453 E 10th St',282), +(193000000000000095,193000000000100095,'IND9','IND9','17','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',181), +(193000000000000273,193000000000100273,'IPA1','IPA1','194','PA','OLYPHANT','18447-2140','UTC-5','1300 Corporate Way',3), +(193000000000000096,193000000000100096,'ITX3','ITX3','187','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',17), +(193000000000000097,193000000000100097,'IUSF','IUSF','169','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',30), +(193000000000000098,193000000000100098,'IUSJ','IUSJ','98','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',77), +(193000000000000099,193000000000100099,'IUSP','IUSP','83','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',82), +(193000000000000274,193000000000100274,'IUSP-AWD','IUSP-AWD','83','CA','HESPERIA','92344','UTC-8','8140 Caliente Rd',91), +(193000000000000100,193000000000100100,'IUSQ','IUSQ','95','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',78), +(193000000000000101,193000000000100101,'IUSR','IUSR','158','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',48), +(193000000000000102,193000000000100102,'IUST','IUST','149','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',47), +(193000000000000103,193000000000100103,'IUSW','IUSW','156','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',41), +(193000000000000104,193000000000100104,'IUTE','IUTE','170','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',29), +(193000000000000105,193000000000100105,'IUTJ','IUTJ','177','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',16), +(193000000000000106,193000000000100106,'IUTL','IUTL','188','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',15), +(193000000000000107,193000000000100107,'IWA6','IWA6','191','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',12), +(193000000000000275,193000000000100275,'JAX3','JAX3','111','','','32210','','JAX3-32210',58), +(193000000000000276,193000000000100276,'JAX3-32210','JAX3-32210','111','FL','Jacksonville','32210-8686','UTC-5','13333 103rd Street',289), +(193000000000000277,193000000000100277,'JAX3-32221','JAX3-32221',NULL,'FL','Jacksonville','32221','UTC-5','13333 103rd Street',175), +(193000000000000108,193000000000100108,'JOT1','JOT1','93','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',80), +(193000000000000109,193000000000100109,'JVL1','JVL1','125','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',158), +(193000000000000110,193000000000100110,'KRB4','KRB4',NULL,'CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',186), +(193000000000000111,193000000000100111,'KY1','KY1','60','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',169), +(193000000000000112,193000000000100112,'LAN2','LAN2','96','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',108), +(193000000000000278,193000000000100278,'LAN2-48917','LAN2-48917',NULL,'MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',109), +(193000000000000113,193000000000100113,'LAS1','LAS1','5','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',255), +(193000000000000114,193000000000100114,'LAS2','LAS2',NULL,'NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',254), +(193000000000000115,193000000000100115,'LAS6','LAS6','50','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',140), +(193000000000000279,193000000000100279,'LAX1','LAX1','192','CA','Chino','91708','UTC-8','6750 Kimball Avenue, Chino,CA 91708,USA',8), +(193000000000000280,193000000000100280,'LAX1-WALMART','LAX1-WALMART',NULL,'CA','Chino','91708','UTC-8','6750 Kimball Avenue',185), +(193000000000000116,193000000000100116,'LAX2T','LAX2T','19','CA','CHINO','91708','UTC-8','6730 Kimball Ave',170), +(193000000000000281,193000000000100281,'LAX2T-WALMART','LAX2T-WALMART',NULL,'CA','Chino','91708','UTC-8','6720 Kimball Ave',188), +(193000000000000117,193000000000100117,'LAX9','LAX9','3','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',191), +(193000000000000118,193000000000100118,'LBE1','LBE1','112','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',94), +(193000000000000282,193000000000100282,'LBE1-UPS','LBE1-UPS',NULL,'PA','New Stanton','15672','UTC-5','165 GLENN FOX RD',93), +(193000000000000119,193000000000100119,'LEX1','LEX1',NULL,'KY','Lexington','40511','UTC-5','1850 Mercer RD',276), +(193000000000000120,193000000000100120,'LEX2','LEX2',NULL,'KY','Lexington','40511','UTC-5','172 Trade St.',275), +(193000000000000121,193000000000100121,'LFT1','LFT1','31','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',115), +(193000000000000122,193000000000100122,'LGB3','LGB3',NULL,'CA','Eastvale','91752','UTC-8','4950 Goodman Way',299), +(193000000000000123,193000000000100123,'LGB4','LGB4','45','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',300), +(193000000000000124,193000000000100124,'LGB6','LGB6','26','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',301), +(193000000000000125,193000000000100125,'LGB8','LGB8','2','CA','Rialto','92376','UTC-8','1568 N Linden Ave',302), +(193000000000000126,193000000000100126,'LIT2','LIT2','159','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',114), +(193000000000000127,193000000000100127,'MCC1','MCC1','161','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',40), +(193000000000000128,193000000000100128,'MCE1','MCE1','65','CA','TURLOCK','95380','UTC-8','FULKERTH RD',165), +(193000000000000129,193000000000100129,'MCI1','MCI1',NULL,'KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',279), +(193000000000000130,193000000000100130,'MCI4','MCI4','176','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',20), +(193000000000000283,193000000000100283,'MCO1-WALMART','MCO1-WALMART',NULL,'FL','Davenport','33897','UTC-5','5100 NORTH RIDGE TRAIL',116), +(193000000000000131,193000000000100131,'MCO2','MCO2','106','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',123), +(193000000000000132,193000000000100132,'MDT1','MDT1','121','PA','Carlisle','17015','UTC-5','2Ames Drive',238), +(193000000000000133,193000000000100133,'MDT4','MDT4','144','PA','York','17408','UTC-5','5125 Commerce Drive',136), +(193000000000000134,193000000000100134,'MDW2','MDW2','46','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',208), +(193000000000000284,193000000000100284,'MDW2-60433-3280','MDW2-60433-3280',NULL,'IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',119), +(193000000000000135,193000000000100135,'MDW6','MDW6','143','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',207), +(193000000000000136,193000000000100136,'MDW7','MDW7',NULL,'IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',206), +(193000000000000137,193000000000100137,'MDW8','MDW8',NULL,'IL','Waukegan','60085','UTC-6','1750 Bridge Drive',205), +(193000000000000138,193000000000100138,'MDW9','MDW9','36','IL','Aurora','60502','UTC-6','2865 Duke Parkway',204), +(193000000000000139,193000000000100139,'MEM1','MEM1','85','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',171), +(193000000000000285,193000000000100285,'MEM1S-WALMART','MEM1S-WALMART',NULL,'MS','OLIVE BRANCH','38654','UTC-6','9200 Alexander Road',183), +(193000000000000140,193000000000100140,'MEM6','MEM6','51','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',126), +(193000000000000141,193000000000100141,'MGE3','MGE3','133','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',209), +(193000000000000286,193000000000100286,'MIA1-33054','MIA1-33054',NULL,'FL','Opa-Locka','33054','UTC-5','14000 NW 37th Avenue',174), +(193000000000000287,193000000000100287,'MIA1-33182','MIA1-33182',NULL,'FL','Miami','33182','UTC-5','1900 NW 132nd Place Doral',290), +(193000000000000142,193000000000100142,'MIT2','MIT2','82','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',85), +(193000000000000143,193000000000100143,'MKC4','MKC4','67','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',278), +(193000000000000144,193000000000100144,'MKC6','MKC6',NULL,'KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',277), +(193000000000000145,193000000000100145,'MKE1','MKE1',NULL,'WI','Kenosha','53144','UTC-6','3501 120th Ave',211), +(193000000000000146,193000000000100146,'MQJ1','MQJ1','88','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',102), +(193000000000000288,193000000000100288,'MQY2','MQY2','193','TN','LEBANON','37090','UTC-6','MQY2 - 1301 Richard Petty Way 37090 - LEBANON, TN - United States',7), +(193000000000000147,193000000000100147,'MSP1','MSP1',NULL,'MN','Shakopee','55379','UTC-6','2601 4th Avenue East',198), +(193000000000000148,193000000000100148,'NJ3','NJ3','175','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',25), +(193000000000000289,193000000000100289,'NJ3-WALMART','NJ3-WALMART',NULL,'NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',87), +(193000000000000290,193000000000100290,'NJ3T','NJ3T','195','NJ','Pedricktown','08067','UTC-5','7 Sorbello Rd',5), +(193000000000000149,193000000000100149,'OAK3','OAK3','41','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',307), +(193000000000000150,193000000000100150,'OAK4','OAK4',NULL,'CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',308), +(193000000000000151,193000000000100151,'OKC2','OKC2','33','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',141), +(193000000000000152,193000000000100152,'OLM1','OLM1','172','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',27), +(193000000000000153,193000000000100153,'ONT2','ONT2',NULL,'CA','San Bernardino','92408','UTC-8','1910 E Central Ave',311), +(193000000000000154,193000000000100154,'ONT6','ONT6',NULL,'CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',310), +(193000000000000155,193000000000100155,'ONT8','ONT8','1','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',309), +(193000000000000156,193000000000100156,'ONT9','ONT9','25','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',306), +(193000000000000291,193000000000100291,'ORD1S-WALMART','ORD1S-WALMART',NULL,'IL','Joliet','60436','UTC-6','3501 Brandon Road',99), +(193000000000000157,193000000000100157,'ORD2','ORD2','21','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',111), +(193000000000000158,193000000000100158,'ORD6','ORD6',NULL,'IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',203), +(193000000000000159,193000000000100159,'ORF2','ORF2','38','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',121), +(193000000000000292,193000000000100292,'ORF2-FEDEX','ORF2-FEDEX',NULL,'VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',167), +(193000000000000293,193000000000100293,'ORF2-UPS','ORF2-UPS',NULL,'VA','Richmond','23297','UTC-5','9603 Coach Road',124), +(193000000000000160,193000000000100160,'PBI3','PBI3','113','FL','Jacksonville','32206','UTC-5','21 Jump Street',89), +(193000000000000161,193000000000100161,'PDX7','PDX7','68','OR','Salem','97317','UTC-8','4775 Depot Ct SE',135), +(193000000000000162,193000000000100162,'PHL1','PHL1',NULL,'DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',296), +(193000000000000163,193000000000100163,'PHL3','PHL3',NULL,'DE','New Castle','19720','UTC-5','1600 Johnson Way',295), +(193000000000000164,193000000000100164,'PHL4','PHL4','52','PA','Carlisle','17015','UTC-5','21 Roadway Dr',244), +(193000000000000165,193000000000100165,'PHL5','PHL5',NULL,'PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',243), +(193000000000000166,193000000000100166,'PHL5S','PHL5S','171','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',28), +(193000000000000294,193000000000100294,'PHL5S-WALMART','PHL5S-WALMART',NULL,'PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct',98), +(193000000000000167,193000000000100167,'PHL6','PHL6','69','PA','Carlisle','17015','UTC-5','675 Allen Rd.',242), +(193000000000000168,193000000000100168,'PHL7','PHL7',NULL,'DE','Middletown','19709','UTC-5','560 Merrimac Ave',294), +(193000000000000169,193000000000100169,'PHL8','PHL8',NULL,'DE','Middletown','19709','UTC-5','727 N Broad St',293), +(193000000000000170,193000000000100170,'PHX3','PHX3',NULL,'AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',315), +(193000000000000171,193000000000100171,'PHX5','PHX5','72','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',314), +(193000000000000172,193000000000100172,'PHX6','PHX6',NULL,'AZ','Phoenix','85043','UTC-7','4750 West Mohave St',313), +(193000000000000173,193000000000100173,'PHX7','PHX7','74','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',312), +(193000000000000174,193000000000100174,'PIT1','PIT1',NULL,'PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',237), +(193000000000000175,193000000000100175,'PIT2','PIT2','78','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',166), +(193000000000000176,193000000000100176,'POC1','POC1','128','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',54), +(193000000000000177,193000000000100177,'POC2','POC2','151','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',46), +(193000000000000178,193000000000100178,'POC3','POC3','129','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',53), +(193000000000000179,193000000000100179,'PPO4','PPO4','122','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',57), +(193000000000000180,193000000000100180,'PSC2','PSC2','75','WA','PASCO','99301','UTC-8','1351 S Road 40 E',101), +(193000000000000295,193000000000100295,'PSC2-UPS','PSC2-UPS',NULL,'OR','Portland','97242','UTC-8','9930 NE Alderwood Rd',100), +(193000000000000181,193000000000100181,'PSP3','PSP3','162','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',36), +(193000000000000182,193000000000100182,'QXY5','QXY5','92','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',79), +(193000000000000183,193000000000100183,'QXY8','QXY8','154','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',43), +(193000000000000184,193000000000100184,'QZZ7','QZZ7','184','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',22), +(193000000000000185,193000000000100185,'RDU2','RDU2','56','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',154), +(193000000000000186,193000000000100186,'RDU4','RDU4','145','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',103), +(193000000000000187,193000000000100187,'RFD2','RFD2','71','IL','Huntley','60142','UTC-6','11500 Freeman Road',153), +(193000000000000188,193000000000100188,'RFD4','RFD4','174','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',24), +(193000000000000189,193000000000100189,'RIC1','RIC1','142','VA','Petersburg','23803','UTC-5','5000 Commerce Way',218), +(193000000000000190,193000000000100190,'RIC2','RIC2',NULL,'VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',217), +(193000000000000191,193000000000100191,'RMN3','RMN3','84','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',152), +(193000000000000192,193000000000100192,'RNO1','RNO1',NULL,'NV','Fernley','89408','UTC-8','1600 East Newlands RD',256), +(193000000000000193,193000000000100193,'RNO2','RNO2',NULL,'NV','Reno','85906','UTC-8','8000 North Virginia Street',253), +(193000000000000194,193000000000100194,'RNO4','RNO4',NULL,'NV','Reno','89506','UTC-8','8000 North Virginia Street',252), +(193000000000000195,193000000000100195,'RYY2','RYY2','94','GA','White','30184','UTC-5','1250 Cassville White Road,',81), +(193000000000000196,193000000000100196,'SAT1','SAT1','23','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',226), +(193000000000000197,193000000000100197,'SAT4','SAT4','61','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',157), +(193000000000000198,193000000000100198,'SAT6','SAT6','183','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',23), +(193000000000000199,193000000000100199,'SAV3','SAV3','124','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',106), +(193000000000000200,193000000000100200,'SBD1','SBD1','4','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',193), +(193000000000000201,193000000000100201,'SBD2','SBD2','15','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',187), +(193000000000000202,193000000000100202,'SCK1','SCK1','20','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',105), +(193000000000000203,193000000000100203,'SCK3','SCK3','54','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',68), +(193000000000000204,193000000000100204,'SCK4','SCK4','10','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',194), +(193000000000000205,193000000000100205,'SCK8','SCK8','141','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',112), +(193000000000000206,193000000000100206,'SDF1','SDF1',NULL,'KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',274), +(193000000000000207,193000000000100207,'SDF2','SDF2',NULL,'KY','Watterson Park','40218','UTC-5','4360 Robards Ln',273), +(193000000000000208,193000000000100208,'SDF4','SDF4',NULL,'KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',272), +(193000000000000209,193000000000100209,'SDF6','SDF6',NULL,'KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',271), +(193000000000000210,193000000000100210,'SDF7','SDF7',NULL,'KY','Shepherdsville','40165','UTC-5','300 Omicron Court',266), +(193000000000000211,193000000000100211,'SDF8','SDF8',NULL,'IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',281), +(193000000000000212,193000000000100212,'SDF9','SDF9',NULL,'KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',265), +(193000000000000213,193000000000100213,'SEA6','SEA6',NULL,'WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',213), +(193000000000000214,193000000000100214,'SEA8','SEA8',NULL,'WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',212), +(193000000000000215,193000000000100215,'SJC7','SJC7','30','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',303), +(193000000000000296,193000000000100296,'SLC1N','SLC1N','196','UT','Salt Lake City','84116','UTC-7','990 N 6550 W',4), +(193000000000000216,193000000000100216,'SLC2','SLC2','86','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',161), +(193000000000000217,193000000000100217,'SLC3','SLC3','185','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',21), +(193000000000000297,193000000000100297,'SMF1-AMAZON','SMF1-AMAZON',NULL,'CA','Sacramento','95835-9505','UTC-8','4900 W Elkhorn Blvd',298), +(193000000000000298,193000000000100298,'SMF1-WALMART','SMF1-WALMART',NULL,'CA','Sacramento','95837','UTC-8','7000 Powerline Rd',173), +(193000000000000218,193000000000100218,'SMF3','SMF3','9','CA','Stockton','95206-8202','UTC-8','3923 S B ST',192), +(193000000000000219,193000000000100219,'SMF6','SMF6','28','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',113), +(193000000000000299,193000000000100299,'SMF7','SMF7','53','','','','','',69), +(193000000000000220,193000000000100220,'SNA4','SNA4','18','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',304), +(193000000000000221,193000000000100221,'STL3','STL3','152','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',117), +(193000000000000222,193000000000100222,'STL4','STL4','115','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',202), +(193000000000000300,193000000000100300,'STL6','STL6','64','','','','','',66), +(193000000000000223,193000000000100223,'SWF1','SWF1','134','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',130), +(193000000000000224,193000000000100224,'SWF2','SWF2','62','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',84), +(193000000000000301,193000000000100301,'SWF2-10710','SWF2-10710',NULL,'NY','Yonkers','10710','UTC-5','557 Tuckahoe Road',145), +(193000000000000302,193000000000100302,'SWF2-12533','SWF2-12533',NULL,'NY','Hopewell Junction','12533-6159','UTC-5','76 Patriot Way',168), +(193000000000000225,193000000000100225,'TCY1','TCY1','139','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',49), +(193000000000000226,193000000000100226,'TCY2','TCY2','138','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',51), +(193000000000000227,193000000000100227,'TEB3','TEB3','47','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',258), +(193000000000000228,193000000000100228,'TEB4','TEB4','55','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',138), +(193000000000000229,193000000000100229,'TEB6','TEB6','114','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',257), +(193000000000000230,193000000000100230,'TEB9','TEB9','40','NJ','Somerset','08873','UTC-5','601 Randolph Road',164), +(193000000000000231,193000000000100231,'TMB8','TMB8','116','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',73), +(193000000000000303,193000000000100303,'TMB8-UPS','TMB8-UPS',NULL,'FL','Miami Gardens','33055','UTC-5','8675309 Jenny Lane',74), +(193000000000000232,193000000000100232,'TOL3','TOL3','173','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',26), +(193000000000000233,193000000000100233,'TPA1','TPA1',NULL,'FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',292), +(193000000000000234,193000000000100234,'TPA2','TPA2','22','FL','Lakeland','33811','UTC-5','1760 County Line Rd',291), +(193000000000000235,193000000000100235,'TPA3','TPA3','146','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',132), +(193000000000000236,193000000000100236,'TUL1','TUL1',NULL,'KS','Coffeyville','67337','UTC-6','2654 North Highway 169',280), +(193000000000000304,193000000000100304,'UPS','UPS','11','','','92337','','10767',56), +(193000000000000237,193000000000100237,'VGT2','VGT2','6','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',180), +(193000000000000305,193000000000100305,'VGT2-FEDEX','VGT2-FEDEX',NULL,'NV','LAS VEGAS','89115-201','UTC-8','6401 Howdy Wells Ave',120), +(193000000000000306,193000000000100306,'VGT2-UPS','VGT2-UPS',NULL,'CA','March Air Reserve Base','92518','UTC-8','20701 Krameria Ave',178), +(193000000000000238,193000000000100238,'VUBA','VUBA',NULL,'PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',236), +(193000000000000239,193000000000100239,'VUGA','VUGA',NULL,'PA','Pittston','18643','UTC-5','508 Delaware Avenue West',235), +(193000000000000240,193000000000100240,'WBW2','WBW2','119','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',90), +(193000000000000241,193000000000100241,'XAV3','XAV3','180','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',44), +(193000000000000307,193000000000100307,'XBN3','XBN3','91','','','','','',61), +(193000000000000308,193000000000100308,'XCH2','XCH2','87','','','','','',63), +(193000000000000242,193000000000100242,'XEW5','XEW5','163','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',45), +(193000000000000243,193000000000100243,'XIN5','XIN5','182','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',33), +(193000000000000244,193000000000100244,'XLX1','XLX1','160','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',42), +(193000000000000245,193000000000100245,'XLX6','XLX6','166','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',31), +(193000000000000246,193000000000100246,'XLX7','XLX7','27','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',71), +(193000000000000309,193000000000100309,'XMI3','XMI3','89','','','','','',62), +(193000000000000247,193000000000100247,'XPB2','XPB2','165','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',34), +(193000000000000248,193000000000100248,'XRI3','XRI3','167','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',32), +(193000000000000249,193000000000100249,'XUSB','XUSB',NULL,'TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',221), +(193000000000000250,193000000000100250,'XUSC','XUSC',NULL,'PA','Carlisle','17013','UTC-5','40 Logistics Drive',234), +(193000000000000251,193000000000100251,'XUSD','XUSD',NULL,'CA','Stockton','95206','UTC-8','1909 Zephyr St',305), +(193000000000000252,193000000000100252,'XUSE','XUSE',NULL,'IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',288), +(193000000000000310,193000000000100310,'洛杉矶仓一件代发','洛杉矶仓一件代发','CADF','CA','RANCHO CUCAMONGA','91730','UTC-8','10767 7TH STREET',60), +(193000000000000311,193000000000100311,'海外仓打托','海外仓打托','13','','','91730','','91730',50), +(193000000000000312,193000000000100312,'私人仓','私人仓','14','','','私人仓','','私人仓',59), +(193000000000000313,193000000000100313,'纽约仓','纽约仓',NULL,'NJ','New Jersey','08817','UTC-5','13、14号门75 Carter Dr Edison,',38), +(193000000000000314,193000000000100314,'纽约仓一件代发','纽约仓一件代发','NJDF','NJ','New Jersey','08817','UTC-5','Door13-14,75 Carter Dr Edison,',37); DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; DELIMITER // CREATE PROCEDURE assert_wh_fba_public_master_data_seed() BEGIN - IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252'; + IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 314 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是314'; END IF; IF EXISTS ( SELECT 1 FROM tmp_wh_fba_public_master_data - WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) - OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$' - OR TRIM(city) = '' - OR TRIM(state_province) = '' - OR TRIM(address_line1) = '' - OR TRIM(postal_code) = '' - OR TRIM(timezone) = '' + WHERE TRIM(warehouse_code) = '' + OR warehouse_code <> UPPER(TRIM(warehouse_code)) + OR (internal_code IS NOT NULL AND internal_code NOT REGEXP '^[A-Z0-9]{1,16}$') ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在空编码或内部编号格式异常'; END IF; IF EXISTS ( @@ -521,7 +580,7 @@ CALL assert_wh_fba_public_master_data_seed(); DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; INSERT INTO wh_warehouse ( - id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version ) @@ -529,6 +588,7 @@ SELECT source.warehouse_id_seed, 0, source.warehouse_code, + source.internal_code, source.warehouse_name, 'FBA', 'US', @@ -546,6 +606,7 @@ SELECT 0 FROM tmp_wh_fba_public_master_data source ON DUPLICATE KEY UPDATE + internal_code = VALUES(internal_code), warehouse_type = VALUES(warehouse_type), country_code = VALUES(country_code), timezone = VALUES(timezone), @@ -601,7 +662,7 @@ BEGIN WHERE warehouse.tenant_id = 0 AND warehouse.warehouse_type = 'FBA' AND warehouse.country_code = 'US' - AND warehouse.deleted = 0) <> 252 THEN + AND warehouse.deleted = 0) <> 314 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败'; END IF; @@ -619,6 +680,7 @@ BEGIN AND address.deleted = 0 WHERE warehouse.id IS NULL OR address.id IS NULL + OR NOT (warehouse.internal_code <=> source.internal_code) OR address.country_code <> 'US' OR address.state_province <> source.state_province OR address.city <> source.city diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index d61950cf9..64b360798 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', + internal_code VARCHAR(16) NULL COMMENT 'FBA内部编号,仅FBA仓可配置', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 340ed1ccf..238c26a13 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -12,7 +12,7 @@ class WarehouseSchemaContractTest { @Test void shouldHandleCase01() throws IOException { String sql = read("warehouse.sql"); - assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code", + assertColumns(sql, "wh_warehouse", "warehouse_code", "internal_code", "warehouse_name", "warehouse_type", "country_code", "timezone", "contact_name", "contact_phone", "contact_email", "status", "remark"); assertColumns(sql, "wh_warehouse_address", "warehouse_id", "address_type", "country_code", "state_province", "city", "district", "postal_code", "address_line1", "address_line2", "latitude", "longitude"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index e22cc4b92..9df80d160 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -61,7 +61,7 @@ class WarehouseApplicationServiceTest { @Test void createsDomesticWarehouseWithPlatformDefaults() { - WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "CN-SZX-01", "深圳作业仓", + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "CN-SZX-01", null, "深圳作业仓", null, null, null, "张三", "13800138000", null, null, null, new WarehouseRequests.AddressRequest(null, null, "广东省", "深圳市", "南山区", "518000", "前海路 1 号", null, null, null), @@ -86,7 +86,7 @@ class WarehouseApplicationServiceTest { @Test void fbaWarehouseUsesConfirmedStateTimezoneAndDoesNotCreateCapabilities() { - WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-LAX9", "LAX9", + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-LAX9", "154", "LAX9", "FBA", "US", null, null, null, null, null, null, new WarehouseRequests.AddressRequest(null, "US", "CA", "Redlands", null, "92374", "Amazon Fulfillment Center", null, null, null), @@ -99,13 +99,28 @@ class WarehouseApplicationServiceTest { verify(repository, never()).insertCapability(any()); assertThat(warehouse.getValue().getTenantId()).isZero(); assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("FBA"); + assertThat(warehouse.getValue().getInternalCode()).isEqualTo("154"); assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8"); assertThat(warehouse.getValue().getStatus()).isEqualTo("ACTIVE"); } + @Test + void rejectsInternalCodeForNonFbaWarehouse() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "CN-SZX-01", "154", "深圳作业仓", + "DOMESTIC", "CN", "UTC+8", null, null, null, null, null, + new WarehouseRequests.AddressRequest(null, "CN", "广东省", "深圳市", null, + "518000", "前海路 1 号", null, null, null), + null); + + assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).insertWarehouse(any()); + } + @Test void fbaWarehouseInMultiTimezoneStateRequiresExplicitTimezone() { - WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-DFW", "DFW", + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-DFW", null, "DFW", "FBA", "US", null, null, null, null, null, null, new WarehouseRequests.AddressRequest(null, "US", "TX", "Dallas", null, "75201", "Amazon", null, null, null), @@ -119,7 +134,7 @@ class WarehouseApplicationServiceTest { @Test void createsOverseasPartnerWarehouseAsPlatformPublicMasterData() { - WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "US-PARTNER-LAX", "洛杉矶合作仓", + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "US-PARTNER-LAX", null, "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "UTC-8", "Alex", "+1-213-555-0100", null, null, null, new WarehouseRequests.AddressRequest(null, "US", "CA", "Los Angeles", null, "90001", "123 Partner Ave", null, null, null), diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java index 1e0837d5a..7c6c75aa3 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java @@ -46,8 +46,8 @@ class WarehouseImportServiceTest { @DisplayName("默认 CREATE_ONLY 只新增新仓库并返回预检分类") void shouldCreateOnlyNewWarehousesByDefault() throws Exception { byte[] file = withRows(service.template(), new String[][] { - {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, - {"FBA-LAX9", "", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} + {"CN-SZX-01", "", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, + {"FBA-LAX9", "154", "", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); @@ -61,6 +61,7 @@ class WarehouseImportServiceTest { verify(warehouseService, org.mockito.Mockito.times(2)).create(captor.capture()); assertThat(captor.getAllValues().get(0).tenantId()).isZero(); assertThat(captor.getAllValues().get(1).warehouseType()).isEqualTo("FBA"); + assertThat(captor.getAllValues().get(1).internalCode()).isEqualTo("154"); assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9"); } @@ -68,7 +69,7 @@ class WarehouseImportServiceTest { @DisplayName("海外合作仓类型可通过批量导入进入公共仓库主数据") void shouldImportOverseasPartnerWarehouse() throws Exception { byte[] file = withRows(service.template(), new String[][] { - {"US-PARTNER-LAX", "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "CA", "Los Angeles", "", "123 Partner Ave", "90001", "UTC-8", "", "", "", "", ""} + {"US-PARTNER-LAX", "", "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "CA", "Los Angeles", "", "123 Partner Ave", "90001", "UTC-8", "", "", "", "", ""} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); @@ -84,8 +85,8 @@ class WarehouseImportServiceTest { @DisplayName("任一行错误时整批零写入") void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception { byte[] file = withRows(service.template(), new String[][] { - {"FBA-DFW", "DFW", "FBA", "US", "TX", "Dallas", "", "Amazon FC", "75201", "", "", "", "", "", ""}, - {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "前海路 1 号", "", "", "", "", "", "", ""} + {"FBA-DFW", "155", "DFW", "FBA", "US", "TX", "Dallas", "", "Amazon FC", "75201", "", "", "", "", "", ""}, + {"CN-SZX-01", "", "深圳作业仓", "", "", "广东省", "深圳市", "", "前海路 1 号", "", "", "", "", "", "", ""} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); @@ -122,7 +123,7 @@ class WarehouseImportServiceTest { when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing); when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address)); byte[] file = withRows(service.template(), new String[][] { - {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "ACTIVE", "新联系人", "新电话", "new@example.com", "新备注"} + {"CN-SZX-01", "", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "ACTIVE", "新联系人", "新电话", "new@example.com", "新备注"} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.UPSERT); @@ -141,8 +142,8 @@ class WarehouseImportServiceTest { @DisplayName("dryRun 只返回预检分类和计数,不写入仓库") void shouldOnlyPreviewWhenDryRun() throws Exception { byte[] file = withRows(service.template(), new String[][] { - {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, - {"FBA-LAX9", "LAX9", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} + {"CN-SZX-01", "", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, + {"FBA-LAX9", "154", "LAX9", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null, true); @@ -175,7 +176,7 @@ class WarehouseImportServiceTest { when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing); when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address)); byte[] file = withRows(service.template(), new String[][] { - {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "", "", "", "", ""} + {"CN-SZX-01", "", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "", "", "", "", ""} }); WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.CREATE_ONLY); From be882d53d1094c1f60c08b13042f2df47c00798c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:42:44 +0800 Subject: [PATCH 15/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 --- .../lastleg/inboundplan/service/InboundPlanServiceTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index a8c826b1d..5e9c7725d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -39,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -78,6 +79,8 @@ class InboundPlanServiceTest { org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(sourcePalletMapper.palletNos(any(), anyLong())) + .thenAnswer(invocation -> List.of("MSCU6639870-WH-" + invocation.getArgument(1))); org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn( new InboundPlanOrderMapper.ReceivingWarehouseSnapshot( "193000000000200003", "US-LAX-01", "洛杉矶仓")); From fc5ce1f45febf2bbc4cd8c3d18bd2176ad167b93 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:46:36 +0800 Subject: [PATCH 16/42] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A2=E5=8A=A1P1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=AE=A2=E6=88=B7=E8=A7=84=E5=88=99=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/finance-full-fixtures.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 4d7b4a27d..cea821aa6 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -353,7 +353,7 @@ async function ensureCustomerChargeRule(context, resource, profile) { const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19) const body = { ruleCode: `FIN_P1_${profile.chargeUnit}_${tenantId}`.slice(0, 50), customerId: tenantId, - customerName: `财务P1验收客户-${tenantId}`.slice(0, 128), businessType, + customerName: String(inbound?.summary?.customerName || `财务P1验收客户-${tenantId}`).slice(0, 128), businessType, chargeMode: profile.chargeUnit, taxMode: 'TAX_EXCLUDED', conversionFactor: 1, roundingScale: 3, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom, effectiveTo: null, remark: `${context.marker}-VOLUME_CBM` From 4fee718424ccc67c293b655713ebea1b0d48e21e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:55:46 +0800 Subject: [PATCH 17/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=BC=96=E5=8F=B7=E5=A2=9E=E9=87=8F=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 28737777b..c4fc1a9a8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -866,6 +866,20 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( KEY idx_wh_country (tenant_id, country_code, deleted) ) ENGINE=InnoDB COMMENT='平台公共仓库主数据表:统一归属平台作用域 tenant_id=0'; +SET @warehouse_internal_code_ddl = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' + AND table_name = 'wh_warehouse' + AND column_name = 'internal_code' + ), + 'SELECT 1', + 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code' +); +PREPARE warehouse_internal_code_statement FROM @warehouse_internal_code_ddl; +EXECUTE warehouse_internal_code_statement; +DEALLOCATE PREPARE warehouse_internal_code_statement; + CREATE TABLE IF NOT EXISTS wh_warehouse_address ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库地址固定使用 tenant_id=0', From 3b02affcb3cecf472d4a305d9b7c4359c320a17c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 17:58:07 +0800 Subject: [PATCH 18/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 --- AGENTS.md | 2 ++ firstleg/AGENTS.md | 2 ++ .../firstleg/controller/PlatformFbaPricingController.java | 3 ++- .../com/baiyue/firstleg/controller/FbaPricingContractTest.java | 2 ++ lastleg-service/AGENTS.md | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e5aaf68af..6ddf0fe03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,8 @@ | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | +散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 + ## 公共模块快速索引 | 关键词 | 模块与入口 | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index b28569360..0b2f42efb 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,6 +9,8 @@ ## 模块入口 +散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 仓编码、三位序号生成冻结标签;重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 + | 目标 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index 1c02c042f..b4bcec533 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -83,7 +83,8 @@ public class PlatformFbaPricingController { } @PutMapping("/published") - @PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')") + @PreAuthorize("hasAnyAuthority('firstleg:fba-pricing:adjust'," + + " 'firstleg:inbound:confirm-send-fee')") @BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId", description = "调整初算或已发布入库费用,不回退待分柜状态") public ApiResponse adjustPublished( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 10aa5422a..717cd6f6d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -23,6 +23,8 @@ class FbaPricingContractTest { + " 'firstleg:fba-pricing:confirm')"); assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:inbound:send-fee')"); + assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust'," + + " 'firstleg:inbound:confirm-send-fee')"); } @Test diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 985848e9a..f255e4cd7 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,6 +9,8 @@ ## 快速索引 +头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 + | 功能 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | From 2a9f79604bdb64004e67745917e79e0778c602e6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:18:26 +0800 Subject: [PATCH 19/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A3=85=E6=9F=9C?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=89=98=E7=9B=98=E6=A0=87=E7=AD=BE=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/service/LoadingListService.java | 2 +- .../firstleg/service/LoadingListServiceTest.java | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 769a69693..d1f32c420 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -577,7 +577,7 @@ public class LoadingListService { } /** 完成 worker 在远程结算前验证最终跨区快照能够完整生成。 */ - @Transactional(readOnly = true) + @Transactional public void requireCompletedEventReady(long planId) { loadingCompletedOutboxService.requirePublishable(planId); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 748dd8fc8..fe004951c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -30,6 +30,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -315,6 +316,16 @@ class LoadingListServiceTest { verify(loadingCompletedOutboxService).publish(10L); } + @Test + void completedEventReadinessMustAllowPalletLabelFreeze() throws Exception { + Transactional transaction = LoadingListService.class + .getDeclaredMethod("requireCompletedEventReady", long.class) + .getAnnotation(Transactional.class); + + assertThat(transaction).isNotNull(); + assertThat(transaction.readOnly()).isFalse(); + } + @Test void shouldRejectAutomaticCompletionWhenPdaItemsRemain() { when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false); From 312501cc7036fb06327e333e872e953e9cf41084 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:19:58 +0800 Subject: [PATCH 20/42] feat: use FBA internal code in pallet labels --- firstleg/AGENTS.md | 2 +- .../client/WarehouseMasterDataClient.java | 2 +- .../baiyue/firstleg/dto/LoadingListDtos.java | 5 +- .../mapper/LoadingPalletLabelMapper.java | 3 +- .../service/LoadingPalletLabelService.java | 68 +++++++++++++++---- .../resources/openapi/loading-lists-v1.yaml | 2 +- .../LoadingListControllerContractTest.java | 2 +- .../service/OceanBookingServiceTest.java | 2 +- .../service/ContainerServiceTest.java | 2 +- .../LoadingPalletLabelServiceTest.java | 19 +++++- 10 files changed, 84 insertions(+), 23 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 0b2f42efb..872160af5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,7 +9,7 @@ ## 模块入口 -散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 仓编码、三位序号生成冻结标签;重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 +散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | |---|---| diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java index d24820e4a..3985ff209 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -18,7 +18,7 @@ public interface WarehouseMasterDataClient { record Detail(Warehouse warehouse) { } - record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName, String warehouseType, String status) { } } 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 924466efc..f3947ae1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -133,13 +133,14 @@ public final class LoadingListDtos { public record PalletLabel( Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode, - String containerNo, String destinationWarehouseCode, String destinationWarehouseName, + String containerNo, String destinationWarehouseCode, String destinationWarehouseInternalCode, + String destinationWarehouseName, int palletIndex, String palletCode ) { } public record PalletLabelGroup( - String destinationWarehouseCode, String destinationWarehouseName, + String destinationWarehouseCode, String destinationWarehouseInternalCode, String destinationWarehouseName, int totalCount, List labels ) { public PalletLabelGroup { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index 5c798ed19..df49db14f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -13,7 +13,7 @@ import java.util.List; 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.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), CEIL(COALESCE(NULLIF(o.pallet_count,0), @@ -56,6 +56,7 @@ public interface LoadingPalletLabelMapper { 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 Long destinationWarehouseId; private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName; private Integer palletCount; private String fulfillmentType; private Integer loadSequence; private String destinationType; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java index a9eafa154..ce875d97c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java @@ -6,6 +6,7 @@ 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.client.WarehouseMasterDataClient; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest; @@ -34,12 +35,13 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */ +/** 散货托盘码按真实柜号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */ @Service public class LoadingPalletLabelService { private static final BigDecimal PAGE_MM = new BigDecimal("100"); @Resource private LoadingPalletLabelMapper mapper; @Resource private PrintPdfClient printClient; + @Resource private WarehouseMasterDataClient warehouseClient; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -58,11 +60,13 @@ public class LoadingPalletLabelService { 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 仓编码,不能生成托盘码"); + if (standard.stream().anyMatch(row -> row.getDestinationWarehouseId() == null + || !StringUtils.hasText(row.getDestinationWarehouseCode()))) { + throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码"); } + Map internalCodes = resolveInternalCodes(standard); List existing = mapper.labels(planId); - List expected = expected(first, standard, containerNo); + List expected = expected(first, standard, containerNo, internalCodes); if (existing.isEmpty()) { expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); }); existing = mapper.labels(planId); @@ -91,19 +95,21 @@ public class LoadingPalletLabelService { catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); } } - private List expected(SourceRow plan, List sources, String containerNo) { - Map indexes = new LinkedHashMap<>(); + private List expected(SourceRow plan, List sources, String containerNo, + Map internalCodes) { + Map indexes = new LinkedHashMap<>(); List result = new ArrayList<>(); for (SourceRow source : sources) { - String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT); + String warehouseCode = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT); + String internalCode = internalCodes.get(source.getDestinationWarehouseId()); for (int local = 0; local < source.getPalletCount(); local++) { - int index = indexes.merge(code, 1, Integer::sum); + int index = indexes.merge(source.getDestinationWarehouseId(), 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)); + row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName()); + row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + internalCode + "-" + String.format("%03d", index)); if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制"); result.add(row); } @@ -111,6 +117,37 @@ public class LoadingPalletLabelService { return result; } + private Map resolveInternalCodes(List sources) { + Map codes = new LinkedHashMap<>(); + Map owners = new LinkedHashMap<>(); + for (SourceRow source : sources) { + long warehouseId = source.getDestinationWarehouseId(); + if (codes.containsKey(warehouseId)) continue; + WarehouseMasterDataClient.Warehouse warehouse; + try { + ApiResponse response = warehouseClient.active(internalToken, 0L, warehouseId); + warehouse = response == null || !response.successful() || response.data() == null + ? null : response.data().warehouse(); + } catch (FeignException | NoFallbackAvailableException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,请稍后重试"); + } + if (warehouse == null || !"FBA".equals(warehouse.warehouseType())) { + throw new BizException(ResultCode.CONFLICT, "目的 FBA 仓不存在或未启用"); + } + String internalCode = normalize(warehouse.internalCode()); + if (internalCode == null) { + throw new BizException(ResultCode.CONFLICT, "FBA 仓“" + warehouse.warehouseCode() + "”未配置内部编号"); + } + internalCode = internalCode.toUpperCase(Locale.ROOT); + Long owner = owners.putIfAbsent(internalCode, warehouseId); + if (owner != null && owner != warehouseId) { + throw new BizException(ResultCode.CONFLICT, "同柜 FBA 仓内部编号重复,请先修正仓库主数据"); + } + codes.put(warehouseId, internalCode); + } + return codes; + } + private void requireSame(List expected, List actual) { List left = expected.stream().map(this::identity).toList(); List right = actual.stream().map(this::identity).toList(); @@ -122,12 +159,19 @@ public class LoadingPalletLabelService { 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.get(0).getDestinationWarehouseCode(), internalCode(values.get(0)), + 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 PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), internalCode(row), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); } + private String internalCode(LabelRow row) { + String prefix = row.getContainerNo() + "-"; + String suffix = "-" + String.format("%03d", row.getPalletIndex()); + String palletCode = row.getPalletCode(); + return palletCode.substring(prefix.length(), palletCode.length() - suffix.length()); + } private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); } private ResponseEntity requirePdf(ResponseEntity response) { diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 773d54235..06cc71d0c 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -77,7 +77,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare: post: summary: 生成并查询散货托盘码清单 - description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。 + description: 预装确认且已有真实柜号后,按柜号-FBA地址内部编号-三位序号生成稳定清单;一件代发不参与。内部编号缺失时拒绝生成。 parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 4f52501c2..f1fb2e42f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -77,7 +77,7 @@ class LoadingListControllerContractTest { .contains("/loading-plans/{planId}/lifecycle") .contains("/loading-plans/{planId}/pallet-labels/prepare") .contains("/loading-plans/{planId}/pallet-labels/print") - .contains("柜号-FBA仓编码-三位序号") + .contains("柜号-FBA地址内部编号-三位序号") .contains("不得以错误文案或 A0404 猜测阶段") .contains("confirmationStatus") .contains("canConfirm") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index ab6b3acd1..643aef318 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -390,7 +390,7 @@ class OceanBookingServiceTest { when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row)); when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success( new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( - 701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE")))); + 701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE")))); when(suppliers.booking(81L, null)).thenReturn( new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"))); when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index ea1f0b0c9..09e195623 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -468,7 +468,7 @@ class ContainerServiceTest { String type, String name) { return ApiResponse.success(new WarehouseMasterDataClient.Detail( - new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE"))); + new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE"))); } private static ContainerBookingEntity booking(String status, int version) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java index 46759a201..2e34d2b2a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.common.core.api.ApiResponse; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -18,21 +20,28 @@ class LoadingPalletLabelServiceTest { LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class); LoadingPalletLabelService service = new LoadingPalletLabelService(); ReflectionTestUtils.setField(service, "mapper", mapper); + WarehouseMasterDataClient warehouseClient = mock(WarehouseMasterDataClient.class); + ReflectionTestUtils.setField(service, "warehouseClient", warehouseClient); + ReflectionTestUtils.setField(service, "internalToken", "test-token"); List saved = new ArrayList<>(); when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"), source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"), source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP"))); when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved)); when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; }); + when(warehouseClient.active("test-token", 0L, 101L)).thenReturn(warehouse("LAX9", "154")); + when(warehouseClient.active("test-token", 0L, 102L)).thenReturn(warehouse("ONT8", "CADF")); var manifest = service.prepare(10L, 20L); assertThat(manifest.totalCount()).isEqualTo(4); assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount()) .containsExactly("LAX9:1", "ONT8:3"); + assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode()) + .containsExactly("154", "CADF"); assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode) - .containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001", - "MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003"); + .containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001", + "MSCU6639870-CADF-002", "MSCU6639870-CADF-003"); } private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) { @@ -40,7 +49,13 @@ class LoadingPalletLabelServiceTest { row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L); row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000); row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination); + row.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L); row.setDestinationWarehouseName(destination); row.setPalletCount(pallets); row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row; } + + private ApiResponse warehouse(String code, String internalCode) { + return ApiResponse.success(new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( + "LAX9".equals(code) ? 101L : 102L, 0L, code, internalCode, code, "FBA", "ACTIVE"))); + } } From acf9bd71356ab8cf10c0292f7a07d51570160bc5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:29:42 +0800 Subject: [PATCH 21/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/docker-compose.yml | 7 +++++++ deploy/scripts/deploy-built-service.sh | 8 ++++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 5 ++++- .../db/phase3-terminal-drayage-schema.sql | 2 +- .../TerminalDrayageSqlContractTest.java | 9 +++++++-- ...est-environment-pipeline-contract.test.mjs | 20 +++++++++++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ec6953f55..9f0c87d39 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -104,6 +104,13 @@ services: CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} + TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false} + TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-} + TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587} + TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-} + TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-} + TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false} + TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000} finance: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index d4ad1ad7d..a351a7a9a 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -130,6 +130,14 @@ if [ "$SERVICE" = "firstleg" ]; then echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 exit 2 fi + if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + fi fi if [ "$SERVICE" = "lastleg-service" ]; then diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c4fc1a9a8..48e19d649 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18269,7 +18269,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', @@ -18304,6 +18304,9 @@ ALTER TABLE fl_terminal_drayage MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; ALTER TABLE fl_terminal_drayage_history + DROP CHECK chk_terminal_drayage_history_action, + ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')), MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', 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 4edf7ade9..eaf6d8cdc 100644 --- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( 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 e98489c6d..7eecedd62 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 @@ -23,7 +23,10 @@ class TerminalDrayageSqlContractTest { 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'))"); + assertThat(Files.readString(path)).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", + "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", + "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')"); } } @@ -38,7 +41,9 @@ class TerminalDrayageSqlContractTest { } 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("); + "ADD CONSTRAINT chk_terminal_drayage_times CHECK(", + "DROP CHECK chk_terminal_drayage_history_action", + "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); } @Test diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 560845a5c..584e27f08 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -130,6 +130,26 @@ test('development publishes every service on loopback without changing test defa assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) }) +test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { + const firstlegService = serviceCompose.slice( + serviceCompose.indexOf(' firstleg:\n'), + serviceCompose.indexOf(' finance:\n') + ) + for (const key of [ + 'TERMINAL_DRAYAGE_MAIL_ENABLED', + 'TERMINAL_DRAYAGE_MAIL_HOST', + 'TERMINAL_DRAYAGE_MAIL_PORT', + 'TERMINAL_DRAYAGE_MAIL_USERNAME', + 'TERMINAL_DRAYAGE_MAIL_PASSWORD', + 'TERMINAL_DRAYAGE_MAIL_SSL', + 'TERMINAL_DRAYAGE_MAIL_SCAN_DELAY' + ]) { + assert.match(firstlegService, new RegExp(`^ ${key}: ` + '\\$\\{' + key, 'm')) + } + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_ENABLED/) + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD/) +}) + test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), From 45e7c98b3f8a7437be801af6267890d243dfc213 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:33:17 +0800 Subject: [PATCH 22/42] feat: receive dropship inbound by source pallet --- lastleg-service/AGENTS.md | 2 +- .../ReceivingContainerController.java | 8 +++ .../pda/dto/ReceivingContainerRequests.java | 2 + .../pda/dto/ReceivingContainerResponses.java | 2 +- .../service/ReceivingContainerService.java | 46 +++++++++++++- .../openapi/overseas-business-v1.yaml | 17 ++++- .../pda/ReceivingContainerContractTest.java | 5 +- ...ReceivingContainerServiceBehaviorTest.java | 32 ++++++++++ .../inbound-measurement.mjs | 5 +- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 1 + .../phase4-overseas-pda-contract.test.mjs | 2 +- .../phase4-overseas-pda.mjs | 63 +++++++++++++++++-- 13 files changed, 174 insertions(+), 12 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 2bcd0a619..99ef44763 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -28,7 +28,7 @@ | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 2cb0fa4c2..a05442781 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -134,6 +134,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } + @PostMapping("/current/receipts/pallet") + public ApiResponse receiveWholePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.WholePalletReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveWholePallet(identity.operatorKey(), identity.operatorName(), request)); + } + @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 6d06a96c9..613a7d18e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -23,4 +23,6 @@ public final class ReceivingContainerRequests { @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, @NotBlank @Size(max = 255) String idempotencyKey) { } + public record WholePalletReceive(@NotBlank String sourcePalletNo, + @NotBlank @Size(max = 255) String idempotencyKey) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 332a2034c..b24602126 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -56,7 +56,7 @@ public final class ReceivingContainerResponses { String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages, boolean assigned, boolean mergeAllowed, boolean urgent, - String mergeHint) { } + String mergeHint, String orderType, boolean wholePalletInbound) { } public record CartonResolved(String cartonMark, String inboundCode, String destinationType, String overseasWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 31ed2e281..741d5eeae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -361,6 +361,46 @@ public class ReceivingContainerService { } } + @Transactional + public ReceivingContainerResponses.Receipt receiveWholePallet(String operatorKey, String operatorName, + ReceivingContainerRequests.WholePalletReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List matches = new ArrayList<>(); + for (InboundRef ref : refs(customerId, session.getContainerNo())) { + if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; + List sourcePallets = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId()) + .eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo())); + for (InboundPlanPallet pallet : sourcePallets) { + List palletAllocations = allocations.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, customerId) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId()) + .eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())); + palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation))); + } + } + if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container"); + if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container"); + DropshipPalletRef match = matches.get(0); + int accepted = value(match.allocation().getPlannedPackageCount()); + workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(), + new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(match.ref()); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(), + accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(), + inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint()); + save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result); + return result; + } + @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { @@ -705,6 +745,9 @@ public class ReceivingContainerService { private record PalletSelection(InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { } + private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet, + InboundPlanPalletAllocation allocation) { } + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() .map(this::inbound).toList(); @@ -729,7 +772,8 @@ public class ReceivingContainerService { ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received), - assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan())); + assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), + ref.order().getOrderType(), "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())); } private boolean assigned(InboundPlan plan) { 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 0a6161332..ad4963ad1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -121,6 +121,13 @@ paths: security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/pallet: + post: + summary: 一件代发按完整来源托盘收货 + description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } } /api/pda/v1/last-leg/receiving-containers/current/complete: post: summary: 完成当前柜收货并入库存 @@ -1518,6 +1525,12 @@ components: cartonMark: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingWholePallet: + type: object + required: [sourcePalletNo, idempotencyKey] + properties: + sourcePalletNo: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } ReceivingApiResponseBase: type: object required: [code, message, timestamp] @@ -1529,7 +1542,7 @@ components: timestamp: { type: string, format: date-time } ReceivingInbound: type: object - required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent] + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent, wholePalletInbound] properties: inboundCode: { type: string } destinationType: { type: string, enum: [FBA, OVERSEAS] } @@ -1641,6 +1654,8 @@ components: mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 } urgent: { type: boolean } mergeHint: { type: string, nullable: true } + orderType: { type: string, nullable: true } + wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 } ReceivingCandidateListApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index f95f2cdf4..aa3d9ac73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -128,14 +128,15 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/receipts/pallet:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(16, successResponses.size()); - assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(17, successResponses.size()); + assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index eec9810df..2209e5f16 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -743,6 +743,38 @@ class ReceivingContainerServiceBehaviorTest { verify(cartons, never()).insert(any(InboundPlanCarton.class)); } + @Test + void dropshipReceivesTheCompleteFrozenSourcePalletThroughTheContainerSession() { + stubCurrent("OVERSEAS", "CONT-A"); + LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropship); + InboundPlanPallet pallet = planPallet(); + pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001"); + InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation(); + pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L); + pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2); + InboundPlanPalletAllocation received = new InboundPlanPalletAllocation(); + received.setId(30L); received.setCustomerId("100"); received.setPlanId(2L); + received.setLineId(10L); received.setPalletId(20L); received.setPlannedPackageCount(2); + received.setPickedPackageCount(2); + when(pallets.selectList(any())).thenReturn(List.of(pallet)); + when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received)); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + + var result = service.receiveWholePallet("7", "receiver", + new ReceivingContainerRequests.WholePalletReceive("DP-260909-0001", "whole-key")); + + assertThat(result.acceptedPackages()).isEqualTo(2); + assertThat(result.receivedPackages()).isEqualTo(2); + assertThat(result.pendingPackages()).isZero(); + assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1"); + verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"), + org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L), + any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class)); + verifyNoInteractions(warehouse); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 5862bf9c2..5d24a901b 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1067,11 +1067,14 @@ export async function run(context) { `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) let sealedCartonMarks = [] + let sealedSourcePalletNos = [] if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { const sealed = await completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, }) sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] + sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED' + ? (sealed.pallets || []).map((item) => item?.palletNo).filter(Boolean) : [] completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) finalResponse = completed.response finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, @@ -1093,7 +1096,7 @@ export async function run(context) { OPTIONS) context.createdResources.inboundMeasurement = { - sealedCartonMarks, + sealedCartonMarks, sealedSourcePalletNos, marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index ff95e185d..a80373bbf 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -506,6 +506,7 @@ export async function run(context) { cartonMark: fixture.cartonMark || null, inboundCode: fixture.inboundCode || null, sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], + sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [], cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f15c690e0..47d0eca4a 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1607,6 +1607,7 @@ export async function run(context) { context.createdResources.phase3Loading = { inboundCode: initial?.summary?.inboundCode || source?.inboundCode, sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [], + sealedSourcePalletNos: Array.isArray(source?.sealedSourcePalletNos) ? source.sealedSourcePalletNos : [], tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, cartonMarks: stableLoadedCartonMarks(stagedTask?.items), diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index a8051009c..3170d1755 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -86,7 +86,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', '/current/cartons/resolve', '/current/pallets/resolve', - '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + '/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/pallet', '/current/complete']) assert.ok(source.includes(token), token) for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { assert.ok(source.includes(token), token) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 40cd6f37d..04a7c437a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -564,6 +564,37 @@ async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures return true } +async function receiveDropship(context, token, producer, inbounds) { + const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos) + ? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : [] + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null, + sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length, + `sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!sourcePalletNos.length) return false + let accepted = 0 + for (const [index, sourcePalletNo] of sourcePalletNos.entries()) { + const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) } + const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict) + await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict) + } + context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null, + accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS) + return accepted === expectedPending +} + async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { if (runAuthorizationProbes && context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) @@ -666,7 +697,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe } const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') - if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + if (!strictDropship && fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' && item.inbound.inboundCode === firstFba.inboundCode) @@ -681,10 +713,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe for (const [index, inbound] of fbaInbounds.entries()) { await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) } - } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + } else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } - if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + if (!strictDropship && overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { const firstOverseas = overseasInbounds[0] const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { @@ -692,9 +724,32 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) - } else if (preferredProducer?.destinationType !== 'FBA') { + } else if (!strictDropship && preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } + if (strictDropship) { + const dropshipInbounds = [...fbaInbounds, ...overseasInbounds] + const firstInbound = dropshipInbounds[0] + const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null + if (!firstInbound || !pallet) { + blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码') + return + } + if (firstInbound.destinationType === 'OVERSEAS') { + await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: cartonFixtures[0]?.cartonMark, + palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/)) + } else { + await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be picked by complete source pallet$/)) + } + await receiveDropship(context, token, preferredProducer, dropshipInbounds) + } const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) From 0d016bc5fcd6b7ee09f1969335949c9d7c636714 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:33:39 +0800 Subject: [PATCH 23/42] =?UTF-8?q?=E9=80=82=E9=85=8D=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6FBA=E5=86=85=E9=83=A8=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance-full-fixtures-contract.test.mjs | 3 +++ .../finance-full-fixtures.mjs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index ab28ff460..26230cb77 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -18,6 +18,9 @@ test('finance fixture module is independently exported and marker isolated', () test('fixture covers weight, volume, manual order, and container allocation contracts', () => { for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis)) assert.match(source, /chargeMode: pricingProfile\.chargeUnit/) + assert.match(source, /warehouse-admin\?tenantId=0&page=/) + assert.match(source, /item\?\.internalCode && item\?\.warehouseCode/) + assert.match(source, /fbaInternalCodes\.has\(String\(rate\.destinationCode\)/) assert.match(source, /customer-rules\/versions\?tenantId=/) assert.match(source, /taxMode: 'TAX_EXCLUDED'/) assert.match(source, /inbound\?\.summary\?\.businessType \|\| profile\.businessType/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index cea821aa6..da7911eeb 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -298,6 +298,23 @@ async function uploadVoucher(context, customerId) { async function loadPricingEvidence(context) { if (context.financeFixtureHooks?.loadPricingEvidence) return context.financeFixtureHooks.loadPricingEvidence(context) + const fbaInternalCodes = new Set() + let warehousePage = 1 + let warehouseTotal = 0 + do { + const warehouseResponse = await context.request('GET', + `/api/platform/v1/warehouse-admin?tenantId=0&page=${warehousePage}&size=200&warehouseType=FBA&status=ACTIVE`) + const warehouseResult = context.apiSuccess(MODULE, `读取具备内部编号的 FBA 仓第 ${warehousePage} 页`, + warehouseResponse, '', OPTIONS) + const warehouses = rows(warehouseResult) + warehouseTotal = Number(warehouseResult?.total || warehouses.length) + warehouses.filter((item) => item?.internalCode && item?.warehouseCode) + .forEach((item) => fbaInternalCodes.add(String(item.warehouseCode).trim().toUpperCase())) + if (warehouses.length === 0) break + warehousePage += 1 + } while ((warehousePage - 1) * 200 < warehouseTotal) + context.assert(MODULE, '自动计费候选使用已配置内部编号的 FBA 仓', null, + fbaInternalCodes.size > 0, `warehouses=${fbaInternalCodes.size}`, OPTIONS) const response = await context.request('GET', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=ACTIVE') const versions = context.apiSuccess(MODULE, '读取真实生效价格配置', response, '', OPTIONS) const active = rows(versions).filter((item) => item?.status === 'ACTIVE') @@ -308,6 +325,7 @@ async function loadPricingEvidence(context) { const rates = Array.isArray(detail?.rates) ? detail.rates : [] for (const rate of rates) { if (rate?.destinationType !== 'FBA' || !rate?.originWarehouseId || !rate?.channelCode || !rate?.pricingTaxMode || !rate?.destinationCode) continue + if (!fbaInternalCodes.has(String(rate.destinationCode).trim().toUpperCase())) continue if (!['KG', 'CBM'].includes(rate.chargeUnit) || profiles.some((item) => item.chargeUnit === rate.chargeUnit)) continue const matches = rates.filter((item) => id(item.originWarehouseId) === id(rate.originWarehouseId) && item.channelCode === rate.channelCode && item.destinationType === rate.destinationType From 1c4f35f61c20a2082ae6c277accaf5f844531ef7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:34:07 +0800 Subject: [PATCH 24/42] test: verify manual inbound unit price flow --- .../inbound-fees-contract.test.mjs | 3 +++ scripts/admin-test-env-modules/inbound-fees.mjs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs index 0aca65035..e20b644b0 100644 --- a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs @@ -27,4 +27,7 @@ test('inbound fee lifecycle covers frozen source, version, publication and WAIT_ test('automatic fee adjustment confirms the ten percent tolerance boundary', async () => { const source = await readFile(moduleFile, 'utf8') assert.match(source, /reason: `\$\{context\.marker\}-自动费用版本调整`, toleranceConfirmed: true/) + assert.match(source, /adjustedUnitPrice = Number\(\(Math\.max\(0, money\(initial\.unitPrice\)\) \+ 0\.37\)\.toFixed\(4\)\)/) + assert.match(source, /人工对客单价写入最新费用版本/) + assert.match(source, /money\(published\?\.unitPrice\) === adjustedUnitPrice/) }) diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs index 2d35c0b4c..433f192ff 100644 --- a/scripts/admin-test-env-modules/inbound-fees.mjs +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -66,7 +66,7 @@ export async function run(context) { } const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01) - const adjustedUnitPrice = Math.max(0, money(initial.unitPrice)) + const adjustedUnitPrice = Number((Math.max(0, money(initial.unitPrice)) + 0.37).toFixed(4)) const adjustedAdditional = money(initial.additionalAmount) + 0.01 const adjustResponse = await context.request('PUT', `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`, @@ -79,6 +79,9 @@ export async function run(context) { Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED' && adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional, `oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '人工对客单价写入最新费用版本', adjustResponse, + money(adjusted?.unitPrice) === adjustedUnitPrice, + `referenceUnitPrice=${initial.unitPrice ?? 'missing'}, selectedUnitPrice=${adjustedUnitPrice}, persistedUnitPrice=${adjusted?.unitPrice ?? 'missing'}`, OPTIONS) if (!adjusted?.feeId) { finish(context, start); return } const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本') @@ -108,7 +111,10 @@ export async function run(context) { && id(row.sourceFeeId) === id(sent?.feeId)) context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse, sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT' - && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED', + && money(sent?.unitPrice) === adjustedUnitPrice + && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED' + && money(published?.unitPrice) === adjustedUnitPrice + && money(published?.amount) === money(sent?.totalAmount), `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=200&status=ACTIVE') From d9603aa7dad089ef04788bc669e2d5b12c19328b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:38:22 +0800 Subject: [PATCH 25/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E4=B8=AD=E7=9A=84IAM=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=8C=BA=E5=9D=97=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 5 +++-- .../ContainerWorkspacePermissionSqlContractTest.java | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 48e19d649..beb436e62 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18493,6 +18493,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 $$ @@ -18524,8 +18527,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/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java index 40f5c5878..64b215294 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -107,7 +107,12 @@ class ContainerWorkspacePermissionSqlContractTest { assertThat(start).isGreaterThanOrEqualTo(0); assertThat(bodyStart).isGreaterThan(start); assertThat(end).isGreaterThan(bodyStart); - assertThat(unified.substring(bodyStart, end).trim()).isEqualTo(sql().trim()); + assertThat(normalizeLineEndings(unified.substring(bodyStart, end)).trim()) + .isEqualTo(normalizeLineEndings(sql()).trim()); + } + + private static String normalizeLineEndings(String value) { + return value.replace("\r\n", "\n"); } private static String sql() throws Exception { From 7678fd85ff73aa43a0144a16b0b0ddd22a3e907d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:43:08 +0800 Subject: [PATCH 26/42] test: cover customer inbound fee confirmation --- .../inbound-fees-contract.test.mjs | 9 +++ .../admin-test-env-modules/inbound-fees.mjs | 60 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs index e20b644b0..d0ac0094c 100644 --- a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs @@ -31,3 +31,12 @@ test('automatic fee adjustment confirms the ten percent tolerance boundary', asy assert.match(source, /人工对客单价写入最新费用版本/) assert.match(source, /money\(published\?\.unitPrice\) === adjustedUnitPrice/) }) + +test('customer confirms chargeable quantity and manual unit price in one locked version', async () => { + const source = await readFile(moduleFile, 'utf8') + assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/) + assert.match(source, /Client 一次确认计费量、对客单价和费用/) + assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) + assert.match(source, /客户费用确认重放返回同一锁定版本/) + assert.match(source, /客户确认后平台不得再次修改计费量或对客单价/) +}) diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs index 433f192ff..0db92fe68 100644 --- a/scripts/admin-test-env-modules/inbound-fees.mjs +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -117,6 +117,64 @@ export async function run(context) { && money(published?.amount) === money(sent?.totalAmount), `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) + const orderId = id(context.createdResources.orders?.orderId) + const merchantLoginResponse = await context.request('POST', '/api/auth/login', { + loginId: context.options.merchantUsername, password: context.options.merchantPassword, + }, '') + const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse, + `orderId=${orderId || 'missing'}`, OPTIONS) + const merchantToken = merchantLogin?.accessToken + context.assert(MODULE, '本轮客户订单与登录令牌可用于费用确认', merchantLoginResponse, + Boolean(orderId && merchantToken), `orderId=${orderId || 'missing'}, token=${merchantToken ? 'present' : 'missing'}`, OPTIONS) + if (!orderId || !merchantToken) { finish(context, start); return } + + const merchantBillingPath = `/api/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing` + const merchantBillingResponse = await context.request('GET', merchantBillingPath, undefined, merchantToken) + const merchantBilling = context.apiSuccess(MODULE, 'Client 读取已推送计费量、对客单价和费用合计', + merchantBillingResponse, '', OPTIONS) + context.assert(MODULE, '客户看到的平台推送版本、对客单价和合计完全一致', merchantBillingResponse, + Number(merchantBilling?.latestFee?.feeVersion) === Number(sent.feeVersion) + && merchantBilling?.latestFee?.feeStatus === 'SENT' + && money(merchantBilling?.latestFee?.unitPrice) === adjustedUnitPrice + && money(merchantBilling?.latestFee?.chargeableQuantity) === money(sent.chargeableQuantity) + && money(merchantBilling?.latestFee?.totalAmount) === money(sent.totalAmount), + `version=${merchantBilling?.latestFee?.feeVersion ?? 'missing'}, unitPrice=${merchantBilling?.latestFee?.unitPrice ?? 'missing'}, total=${merchantBilling?.latestFee?.totalAmount ?? 'missing'}`, OPTIONS) + + const customerConfirmKey = `${context.marker}-customer-fee-confirm`.slice(0, 128) + const customerConfirmResponse = await context.request('POST', `${merchantBillingPath}/confirm`, + { expectedFeeVersion: Number(sent.feeVersion) }, merchantToken, requestHeaders(customerConfirmKey)) + const customerConfirmed = context.apiSuccess(MODULE, 'Client 一次确认计费量、对客单价和费用', + customerConfirmResponse, '', OPTIONS) + context.assert(MODULE, '客户确认锁定原推送版本及其单价和合计', customerConfirmResponse, + customerConfirmed?.customerConfirmationStatus === 'CONFIRMED' + && Number(customerConfirmed?.feeVersion) === Number(sent.feeVersion) + && money(customerConfirmed?.unitPrice) === adjustedUnitPrice + && money(customerConfirmed?.chargeableQuantity) === money(sent.chargeableQuantity) + && money(customerConfirmed?.totalAmount) === money(sent.totalAmount) + && Boolean(customerConfirmed?.customerConfirmedAt), + `confirmation=${customerConfirmed?.customerConfirmationStatus || 'missing'}, version=${customerConfirmed?.feeVersion ?? 'missing'}, unitPrice=${customerConfirmed?.unitPrice ?? 'missing'}`, OPTIONS) + + const customerConfirmReplayResponse = await context.request('POST', `${merchantBillingPath}/confirm`, + { expectedFeeVersion: Number(sent.feeVersion) }, merchantToken, requestHeaders(customerConfirmKey)) + const customerConfirmReplay = context.apiSuccess(MODULE, 'Client 费用确认幂等重放', + customerConfirmReplayResponse, '', OPTIONS) + context.assert(MODULE, '客户费用确认重放返回同一锁定版本', customerConfirmReplayResponse, + customerConfirmReplay?.customerConfirmationStatus === 'CONFIRMED' + && id(customerConfirmReplay?.feeId) === id(customerConfirmed?.feeId) + && Number(customerConfirmReplay?.feeVersion) === Number(customerConfirmed?.feeVersion), + `feeId=${id(customerConfirmReplay?.feeId) || 'missing'}, version=${customerConfirmReplay?.feeVersion ?? 'missing'}`, OPTIONS) + + const lockedAdjustResponse = await context.request('PUT', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`, + { expectedFeeVersion: Number(sent.feeVersion), chargeableQuantity: money(sent.chargeableQuantity), + unitPrice: Number((adjustedUnitPrice + 0.01).toFixed(4)), additionalAmount: money(sent.additionalAmount), + reason: `${context.marker}-客户确认后禁止改单价`, toleranceConfirmed: true }, undefined, + requestHeaders(`${context.marker}-locked-fee-adjust`)) + context.assert(MODULE, '客户确认后平台不得再次修改计费量或对客单价', lockedAdjustResponse, + lockedAdjustResponse.http === 200 && !success(lockedAdjustResponse) + && String(lockedAdjustResponse.body?.code) === 'A0409', + `code=${lockedAdjustResponse.body?.code ?? 'missing'}, message=${lockedAdjustResponse.body?.message || 'missing'}`, OPTIONS) + const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=200&status=ACTIVE') const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS) const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records @@ -190,6 +248,6 @@ export async function run(context) { context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId, freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id), - manualVersion: manualV2.sourceFeeVersion } + manualVersion: manualV2.sourceFeeVersion, customerConfirmationStatus: customerConfirmed.customerConfirmationStatus } finish(context, start) } From 1e66fc6c226d613b7a7b7915908c7ab06f56ba4e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:07:13 +0800 Subject: [PATCH 27/42] fix: enforce dropship whole-pallet receiving --- .../pda/service/ReceivingContainerService.java | 3 +++ .../ReceivingContainerServiceBehaviorTest.java | 16 ++++++++++++++++ ...nbound-measurement-box-code-contract.test.mjs | 1 + .../inbound-measurement.mjs | 4 ++-- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 741d5eeae..84c787670 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -289,6 +289,9 @@ public class ReceivingContainerService { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { + throw conflict("One-click dropship inbound must be operated by complete source pallet"); + } if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 2209e5f16..2d65eb5a1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -364,6 +364,22 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(warehouse); } + @Test + void dropshipFbaBulkReceiveIsRejectedBeforeWarehouseCalls() { + stubCurrent("FBA", "CONT-A"); + LastlegOrder dropshipOrder = order("FBA"); + dropshipOrder.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropshipOrder); + + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "dropship-bulk-key"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("complete source pallet"); + + verifyNoInteractions(warehouse); + verifyNoInteractions(workflow); + } + @Test void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { stubCurrent("OVERSEAS", "CONT-A"); diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 40c1a906d..53f5a01c9 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -177,6 +177,7 @@ async function exerciseSystemPalletScenario(fault = '') { } const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } }) assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2']) + assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001']) return checks } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 5d24a901b..403c03a5e 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -257,7 +257,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse, sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed), `taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS) - return { ...sealed, sealedCartonMarks } + return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [palletNo] } } async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) { @@ -1074,7 +1074,7 @@ export async function run(context) { }) sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED' - ? (sealed.pallets || []).map((item) => item?.palletNo).filter(Boolean) : [] + ? (sealed.sealedSourcePalletNos || []).filter(Boolean) : [] completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) finalResponse = completed.response finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, From 30b9db96283878efd97a245aa3c8cc44c6999b4c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:11:37 +0800 Subject: [PATCH 28/42] test: align tail fee acceptance with current contract --- .../phase4-tail-order-controls-contract.test.mjs | 6 ++++++ .../phase4-tail-order-controls.mjs | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 429e9794b..4611da6ae 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -33,6 +33,12 @@ test('module covers the public tail-order write surface before PDA receiving', ( assert.match(source, /creationMode === 'AUTO'/) assert.match(source, /requestOverseasBinary\('GET'/) assert.match(source, /palletMergeBatchId/) + assert.match(source, /feeItemCode/) + assert.match(source, /feeItemName/) + assert.match(source, /direction: 'RECEIVABLE'/) + assert.match(source, /businessDate:/) + assert.match(source, /reviewStatus === 'PENDING_REVIEW'/) + assert.doesNotMatch(source, /supplierDescription/) }) test('receiving warehouse verification reuses public master data and restores the original assignment', () => { diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 19c9e44ba..fd946d34e 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -168,17 +168,22 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { { orderIds: allIds, palletMergeRequired: false }) context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS) - const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) + const feeItemCode = `${context.marker}-TAIL-FEE`.slice(0, 64) + const feeItemName = `尾程费用-${context.marker}`.slice(0, 200) const fee = await api(context, 'POST', '新增本轮尾程订单费用', query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), - { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) + { feeItemCode, feeItemName, direction: 'RECEIVABLE', currency: 'USD', amount: 0.01, + businessDate: new Date().toISOString().slice(0, 10) }) context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, - id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem - && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemCode === feeItemCode + && fee.data?.feeItemName === feeItemName && fee.data?.direction === 'RECEIVABLE' + && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 + && fee.data?.reviewStatus === 'PENDING_REVIEW', trace(fee.response), OPTIONS) const feeRead = await detail(context, representative, '回查尾程订单费用明细') context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, - feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) + feeRead.data?.fees?.some((row) => row?.feeItemCode === feeItemCode && row?.feeItemName === feeItemName + && row?.direction === 'RECEIVABLE' && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { customerId: id(representative.customerId), containerNo: producer.containerNo, From e30255ff58341b62d20269e9d99538153bb52ea3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:14:47 +0800 Subject: [PATCH 29/42] fix: complete pallet code cross-region flow --- ...ainerInboundPlanConversionCoordinator.java | 7 +- ...rInboundPlanConversionCoordinatorTest.java | 32 +++++++++ .../phase3-loading-finalize.mjs | 66 +++++++++++++++++++ .../phase3-producer-chain-contract.test.mjs | 19 ++++++ .../phase4-overseas-pda.mjs | 18 +++++ .../phase4-tail-order-controls.mjs | 14 ++-- .../dto/WarehousePalletBatchRequests.java | 2 +- .../service/WarehousePalletBatchService.java | 4 +- .../WarehousePalletBatchServiceTest.java | 22 +++++++ 9 files changed, 175 insertions(+), 9 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 3f3767265..5eff2d693 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -209,13 +209,16 @@ public class ContainerInboundPlanConversionCoordinator { String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); List sourcePalletNos = plans.sourcePalletNos( order.customerId(), Long.parseLong(order.orderId()), order.orderType()); - if (sourcePalletNos.size() != order.suggestedPalletCount()) { + boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null + && !"ONE_CLICK_DROPSHIP".equals(order.orderType()); + if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致"); } for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, - trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1))); + trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets + ? null : sourcePalletNos.get(localSequence - 1))); } } Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 084121a52..2dea90075 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -258,6 +258,38 @@ class ContainerInboundPlanConversionCoordinatorTest { .map(ContainerInboundPlanConversionCoordinator.PreparedItem::fbaWarehouseCode).toList()); } + @Test + void standardOverseasOrdersLetWarehouseGeneratePhysicalPalletCodes() { + ConvertContainerInboundPlanRequest request = request(); + var overseas = new ContainerInboundPlanPreviewResponse.Order( + "200", "Customer 200", "12", "LM-12", "RK-OVERSEAS", "FIRST_LEG", "FIRST_LEG", + "31", "US-LAX-01", "Los Angeles", null, 10, BigDecimal.ONE, 2, + null, null, null, 0, "READY", List.of()); + var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 2, 2, 0, 1, 0, true, + List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles", + 1, 1, 2, 0, 2, 0, List.of(overseas)))); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); + when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS"); + when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of()); + + ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", + 1L, 2L, BOOKING_ID, "FIRST-KEY", request, + ReflectionTestUtils.invokeMethod(coordinator, "hash", + List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash()))); + ContainerInboundPlanConversionCoordinator.Preparation preparation; + try { + preparation = objectMapper.readValue(command.getPreparationJson(), + ContainerInboundPlanConversionCoordinator.Preparation.class); + } catch (Exception exception) { + throw new AssertionError(exception); + } + + assertEquals(2, preparation.items().size()); + assertEquals(java.util.Arrays.asList(null, null), preparation.items().stream() + .map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList()); + } + private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId, String inboundCode, String destinationCode, String fbaWarehouseCode) { diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 8d705c2d0..a6a16f044 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -78,6 +78,70 @@ async function request(context, method, path, body, headers = {}) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } + +async function requestPdf(context, path, body) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/pdf', Authorization: `Bearer ${context.token}`, + 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + return { method: 'POST', path, http: response.status, body: null, raw: null, + contentType: response.headers.get('content-type'), byteLength: bytes.length, + pdfMagic: String.fromCharCode(...bytes.slice(0, 4)), + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path, http: 0, body: null, raw: String(error), contentType: null, + byteLength: 0, pdfMagic: null, requestId: null, traceId: null } + } +} + +async function verifyPalletLabels(context, fixture, loadingPlanId) { + if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null + const warehouseId = id(fixture.warehouseId) + const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels` + const path = `${base}/prepare?warehouseId=${encodeURIComponent(warehouseId)}` + const preparedResponse = await context.request('POST', path, {}) + const manifest = context.apiSuccess(MODULE, '生成并读取按 FBA 仓分组的散货托盘码清单', preparedResponse, '', OPTIONS) + const labels = (manifest?.groups || []).flatMap((group) => group?.labels || []) + const codesValid = labels.length > 0 && labels.every((label) => { + const index = Number(label?.palletIndex) + return id(label?.containerNo) === id(fixture.containerNo) + && id(label?.destinationWarehouseInternalCode) + && id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}` + }) + const indexesValid = (manifest?.groups || []).every((group) => + (group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1)) + context.assert(MODULE, '散货托盘码使用柜号-FBA内部编号-仓内连续下标', preparedResponse, + id(manifest?.containerNo) === id(fixture.containerNo) + && Number(manifest?.totalCount) === labels.length && codesValid && indexesValid, + `containerNo=${manifest?.containerNo || 'missing'}, groups=${manifest?.groups?.length ?? 0}, labels=${labels.length}`, + OPTIONS) + + const replayResponse = await context.request('POST', path, {}) + const replay = context.apiSuccess(MODULE, '重复生成散货托盘码返回稳定清单', replayResponse, '', OPTIONS) + context.assert(MODULE, '散货托盘码重复生成零漂移', replayResponse, + JSON.stringify(replay) === JSON.stringify(manifest), `labels=${labels.length}`, OPTIONS) + + const printPath = `${base}/print?warehouseId=${encodeURIComponent(warehouseId)}` + const allPdf = await requestPdf(context, printPath, { destinationWarehouseCode: null, count: null }) + context.addStep(MODULE, '打印全部 FBA 仓散货托盘码 PDF', allPdf, + allPdf.http === 200 && allPdf.contentType?.toLowerCase().includes('application/pdf') + && allPdf.pdfMagic === '%PDF' && allPdf.byteLength > 0, + `HTTP=${allPdf.http}, type=${allPdf.contentType || 'missing'}, bytes=${allPdf.byteLength}`, OPTIONS) + const group = manifest?.groups?.[0] + const selectedPdf = await requestPdf(context, printPath, + { destinationWarehouseCode: group?.destinationWarehouseCode, count: 1 }) + context.addStep(MODULE, '按指定 FBA 仓补打 1 张托盘码 PDF', selectedPdf, + Boolean(group?.destinationWarehouseCode) && selectedPdf.http === 200 + && selectedPdf.contentType?.toLowerCase().includes('application/pdf') + && selectedPdf.pdfMagic === '%PDF' && selectedPdf.byteLength > 0, + `warehouse=${group?.destinationWarehouseCode || 'missing'}, HTTP=${selectedPdf.http}, bytes=${selectedPdf.byteLength}`, + OPTIONS) + return manifest +} function blockResourceIdentity(resource) { return JSON.stringify({ blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo), @@ -490,6 +554,7 @@ export async function run(context) { inventoryBlockSettlementVerified: expectedBlocks.length > 0 && releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased, } + const palletLabelManifest = await verifyPalletLabels(context, fixture, loadingPlanId) const producer = { marker: context.context?.phase4ProducerMatrixMarker || context.marker, eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', @@ -511,6 +576,7 @@ export async function run(context) { destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, + palletLabelManifest, containerNo: fixture.containerNo || null, sourceStatus: 'LOADED', verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index a3b21bb01..8fd53a5c5 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -69,6 +69,25 @@ test('records LOADED and clears the reason only after a real LOADED reread', () assert.equal(result.notCleanedReason, null) }) +test('finalize acceptance covers stable pallet generation and both print modes', () => { + const value = source('./phase3-loading-finalize.mjs') + assert.match(value, /pallet-labels`/) + assert.match(value, /`\$\{base\}\/prepare\?warehouseId=/) + assert.match(value, /柜号-FBA内部编号-仓内连续下标/) + assert.match(value, /重复生成散货托盘码返回稳定清单/) + assert.match(value, /destinationWarehouseCode: null, count: null/) + assert.match(value, /按指定 FBA 仓补打 1 张托盘码 PDF/) + assert.match(value, /pdfMagic === '%PDF'/) + assert.match(value, /palletLabelManifest,/) +}) + +test('overseas conversion proves source-code preservation and native code generation boundaries', () => { + const value = source('./phase4-overseas-pda.mjs') + assert.match(value, /头程 FBA 散货托盘码跨区原样用于实体托盘/) + assert.match(value, /标准海外仓订单由海外仓生成本地实体托盘码/) + assert.match(value, /actualPalletCodes\.every\(\(code\) => code\.startsWith\('PLT-'\)\)/) +}) + test('container loading follows push, claim, real pick, completed sequence', () => { const value = source('./phase3-container-loading.mjs') assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 87c6fb98c..41c9bdf2a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -350,6 +350,24 @@ async function convertReceivingContainers(context, producers) { && convertedInboundCodes.size === inboundCodes, `plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS) if (plans.length !== inboundCodes) return false + const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : []) + .map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort() + const expectedPalletCodes = (producer?.palletLabelManifest?.groups || []) + .flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort() + if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse, + expectedPalletCodes.length > 0 + && JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes), + `source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } + if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse, + expectedPalletCodes.length === 0 && actualPalletCodes.length > 0 + && actualPalletCodes.every((code) => code.startsWith('PLT-')), + `sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } rememberGeneratedPlans(context, plans) const replayResponse = await context.requestOverseas('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 19c9e44ba..65edf988f 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -171,14 +171,20 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) const fee = await api(context, 'POST', '新增本轮尾程订单费用', query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), - { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) + { feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE', + currency: 'USD', amount: 0.01, businessDate: new Date().toISOString().slice(0, 10) }, + { 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) }) context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, - id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem - && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem + && fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01 + && fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0 + && fee.data?.reviewStatus === 'PENDING', trace(fee.response), OPTIONS) const feeRead = await detail(context, representative, '回查尾程订单费用明细') context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, - feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) + feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem + && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 + && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { customerId: id(representative.customerId), containerNo: producer.containerNo, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java index 1cf904435..647ff1a70 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -24,7 +24,7 @@ public final class WarehousePalletBatchRequests { @Positive int sequenceNo, @NotBlank @Size(max = 32) String palletType, @Size(max = 128) String fbaWarehouseCode, - @NotBlank @Size(max = 128) String palletCode + @Size(max = 128) String palletCode ) { } public record Confirm( diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index f0de25ab7..407b7ecf1 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -257,8 +257,8 @@ public class WarehousePalletBatchService { if (item.fbaWarehouseCode() != null) { requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long"); } - String palletCode = required(item.palletCode(), "Pallet code is required"); - requireMax(palletCode, 128, "Pallet code is too long"); + String palletCode = trim(item.palletCode()); + if (palletCode != null) requireMax(palletCode, 128, "Pallet code is too long"); if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique"); if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java index bde250a7b..c1baf254d 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -86,6 +86,28 @@ class WarehousePalletBatchServiceTest { .containsExactly("container:plan-a:1", "container:plan-b:2"); } + @Test + void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() { + when(batches.insertIgnore(any())).thenReturn(1); + when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L); + when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301")); + when(locks.reserve(any())).thenAnswer(call -> { + WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0); + return lockResponse(value.customerId(), 501L); + }); + var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-generated", List.of( + new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:generated:1", "PLAN-G", 1, + "STANDARD", null, null))); + + var response = service.ensureReserved(request); + + ArgumentCaptor saves = ArgumentCaptor.forClass(PalletRequests.SaveRequest.class); + verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE), + org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture()); + assertThat(saves.getValue().palletCode()).isNull(); + assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301"); + } + @Test void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() { AtomicReference inserted = new AtomicReference<>(); From 90a0b009da29d6c731bf9ead3b6f1d323924ef3e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:16:18 +0800 Subject: [PATCH 30/42] test: keep overseas acceptance fixtures independent --- ...rseas-inbound-operations-contract.test.mjs | 6 +++++- .../phase4-overseas-inbound-operations.mjs | 21 ++++++++++++++----- .../phase4-overseas-pda-contract.test.mjs | 3 +++ .../phase4-overseas-pda.mjs | 6 +++++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs index 4f275d6a2..1d11cd6e0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs @@ -8,8 +8,12 @@ const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-inbound-operations.mjs'), 'utf8') test('module covers the frozen Admin inbound-plan write surface', () => { - for (const value of ['/convert', '/pallets/supplement', '/fba-appointments/batch', + for (const value of ['/container-preview', '/convert-container', '/pallets/supplement', '/fba-appointments/batch', '/fba-appointments/${encodeURIComponent(appointmentNo)}']) assert.ok(source.includes(value), value) + assert.equal(source.includes("`${PLAN_BASE}/convert`"), false) + assert.match(source, /snapshotVersion/) + assert.match(source, /snapshotHash/) + assert.match(source, /'Idempotency-Key'/) assert.match(source, /重复转换返回同一入仓计划/) assert.match(source, /FBA预约同单号异载荷返回A0409/) assert.match(source, /appointmentStatus: 'CANCELLED'/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index a28ce8ee6..2341d9aea 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -15,8 +15,8 @@ function query(path, values) { } function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) } -async function request(context, method, name, path, body) { - const response = await context.requestOverseas(method, path, body) +async function request(context, method, name, path, body, headers = {}) { + const response = await context.requestOverseas(method, path, body, headers) context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS) return ok(response) ? response.body?.data : null } @@ -40,9 +40,20 @@ async function inboundAdminLifecycle(context) { `orderId=${id(order?.id) || 'missing'}, containerNo=${producer.containerNo}`, OPTIONS) if (!order?.id) return - const convertBody = { orderId: id(order.id), customerId } - const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody) - const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody) + const bookingId = id(producer.containerBookingId) + const preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, { + sourceContainerBookingId: bookingId, + })) + const convertBody = { sourceContainerBookingId: bookingId, + snapshotVersion: Number(preview?.snapshotVersion), snapshotHash: preview?.snapshotHash } + const convertHeaders = { 'Idempotency-Key': key(context, 'convert-container') } + const plans = await request(context, 'POST', '尾程入仓单转换为入仓计划', + `${PLAN_BASE}/convert-container`, convertBody, convertHeaders) + const replayedPlans = await request(context, 'POST', '重复转换返回同一入仓计划', + `${PLAN_BASE}/convert-container`, convertBody, convertHeaders) + const plan = (plans || []).find((item) => (item?.lines || []).some((line) => id(line?.orderId) === id(order.id))) + || plans?.[0] + const replay = (replayedPlans || []).find((item) => item?.planNo === plan?.planNo) || replayedPlans?.[0] context.assert(MODULE, '入仓计划转换天然幂等', null, Boolean(plan?.planNo) && replay?.planNo === plan.planNo, `first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 3170d1755..35543db25 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -128,6 +128,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /rememberGeneratedPlans\(context, plans\)/) assert.match(source, /resolveGeneratedPallet\(context, inbound\)/) assert.match(source, /generatedPalletsByInbound/) + assert.match(source, /standardFba = matrix\?\.byDestinationType\?\.FBA\?\.find/) + assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/) + assert.match(source, /const producers = \[dropship[\s\S]*?standardFba,/) assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/) assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 04a7c437a..0fa01c902 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -833,7 +833,11 @@ export async function run(context) { } const matrix = context.createdResources?.phase4InboundProducerFixtures const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') - const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + // The sealed dropship pallet proves the indivisible handover. A separate standard FBA + // fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks. + const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba, + matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length) await awaitProducerConsumption(context, allProducers) From 4cc3e5372bb20cb10b35cf3507e045bb035c2b47 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:24:10 +0800 Subject: [PATCH 31/42] fix: allow native pallets for overseas destinations --- .../service/InboundPlanService.java | 4 ++- .../service/InboundPlanServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 7fe420dee..761e892bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -533,7 +533,9 @@ public class InboundPlanService { wholePallets.add(sourcePallets); List standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of() : sourcePalletMapper.palletNos(ref.customerId(), ref.orderId()); - if (sourcePallets == null && standardNos.isEmpty()) throw new ResponseStatusException( + boolean standardFba = sourcePallets == null && orders.stream() + .anyMatch(order -> trimToNull(order.fbaWarehouseCode()) != null); + if (standardFba && standardNos.isEmpty()) throw new ResponseStatusException( HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照"); standardPalletNos.add(standardNos); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 5e9c7725d..b6b616fe4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -478,6 +478,41 @@ class InboundPlanServiceTest { .map(InboundPlanPalletAllocation::getPlannedPackageCount).toList()); } + @Test + void standardOverseasContainerConversionAllowsMissingSourcePalletSnapshot() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", + "MSCU6639870", "STANDARD"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); + when(sourcePalletMapper.palletNos("100", 1L)).thenReturn(List.of()); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convertContainer("BOOKING-1"); + + assertEquals(1, result.size()); + verify(planMapper).insert(any(InboundPlan.class)); + } + + @Test + void standardFbaContainerConversionStillRequiresSourcePalletSnapshot() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", + "MSCU6639870", "STANDARD"))); + SourceOrderSnapshot fba = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 1, + "193000000000200003", "US-LAX-01", "洛杉矶仓", "ABE3", + "FBA-1", "ABE3", "ABE3", 1, 0); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(fba)); + when(sourcePalletMapper.palletNos("100", 1L)).thenReturn(List.of()); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.convertContainer("BOOKING-1")); + + assertEquals("散货订单缺少头程托盘码快照", error.getReason()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + @Test void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() { when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( From 5ccce6af712c836cac6cba216ec761c39f8324d2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:25:13 +0800 Subject: [PATCH 32/42] test: carry sealed pallet into overseas acceptance --- scripts/admin-test-env-acceptance.mjs | 1 + .../phase3-phase4-producer-fixture-contract.test.mjs | 1 + scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 2 +- .../phase4-tail-order-controls-contract.test.mjs | 5 ++++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index f66b5682a..fea49054c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1381,6 +1381,7 @@ export class AcceptanceRun { inboundId: fixture.inboundId, boxCode: fixture.boxCode, sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], + sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [], fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [], skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [], diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index faed3eb0e..e7129fd93 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -92,6 +92,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) assert.match(entry, /inboundFees\.ensureInitialCalculated\(this,/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 93bc4fc0f..e0ec1df74 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -764,7 +764,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'), - }, (response) => a0409Message(response, /^One-click dropship inbound must be picked by complete source pallet$/)) + }, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/)) } await receiveDropship(context, token, preferredProducer, dropshipInbounds) } diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 4611da6ae..35edae658 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -37,7 +37,10 @@ test('module covers the public tail-order write surface before PDA receiving', ( assert.match(source, /feeItemName/) assert.match(source, /direction: 'RECEIVABLE'/) assert.match(source, /businessDate:/) - assert.match(source, /reviewStatus === 'PENDING_REVIEW'/) + assert.match(source, /originalCurrency === 'USD'/) + assert.match(source, /currency === 'CNY'/) + assert.match(source, /reviewStatus === 'PENDING'/) + assert.match(source, /'Idempotency-Key': `\$\{context\.marker\}-tail-fee`/) assert.doesNotMatch(source, /supplierDescription/) }) From b3e0ea9199374c577a7227bdb454b3404296b91d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:41:29 +0800 Subject: [PATCH 33/42] fix: complete dropship container after pallet receipt --- .../service/ReceivingContainerService.java | 1 + ...ReceivingContainerServiceBehaviorTest.java | 18 ++++++++++++++ ...ase4-tail-order-controls-contract.test.mjs | 4 ++++ .../phase4-tail-order-controls.mjs | 24 ++++++++++++++++++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 84c787670..f0481134b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -927,6 +927,7 @@ public class ReceivingContainerService { private void validateFbaSkuBalance(String customerId, String containerNo) { for (InboundRef ref : refs(customerId, containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; List skuCodes = skuExpectations(ref); int received = 0; for (SkuExpectation sku : skuCodes) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 2d65eb5a1..f078d5977 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -448,6 +448,24 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow, never()).completeContainer(any(), any()); } + @Test + void dropshipWholePalletCanCompleteWithoutBulkSkuReceiptRows() { + stubCurrent("FBA", "CONT-A"); + LastlegOrder dropshipOrder = order("FBA"); + dropshipOrder.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropshipOrder); + InboundPlanPalletAllocation received = new InboundPlanPalletAllocation(); + received.setLineId(10L); received.setPickedPackageCount(2); + when(allocations.selectList(any())).thenReturn(List.of(received)); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + + var completed = service.complete("7", new ReceivingContainerRequests.Action("dropship-complete")); + + assertThat(completed.status()).isEqualTo("COMPLETED"); + verifyNoInteractions(skuReceipts); + verify(workflow).completeContainer(any(), any()); + } + @Test void completedRequestReplaysWithoutAnActiveCustomerSession() { ReceivingContainerOperation operation = new ReceivingContainerOperation(); diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 35edae658..64fe96acb 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -41,6 +41,10 @@ test('module covers the public tail-order write surface before PDA receiving', ( assert.match(source, /currency === 'CNY'/) assert.match(source, /reviewStatus === 'PENDING'/) assert.match(source, /'Idempotency-Key': `\$\{context\.marker\}-tail-fee`/) + assert.match(source, /ensureCurrentUsdRate/) + assert.match(source, /monthFrom=\$\{month\}&monthTo=\$\{month\}/) + assert.match(source, /existing\?\.status === 'ACTIVE'/) + assert.match(source, /businessDate: `\$\{feeMonth\}-01`/) assert.doesNotMatch(source, /supplierDescription/) }) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 65edf988f..3201024a2 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -63,6 +63,26 @@ async function detail(context, row, name = '读取尾程订单即时详情') { return api(context, 'GET', name, query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) })) } +async function ensureCurrentUsdRate(context) { + const month = new Date().toISOString().slice(0, 7) + const path = `/api/platform/v1/last-leg/master-data/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}` + const listed = await api(context, 'GET', '查询尾程费用业务月汇率', path) + const existing = records(listed.data).find((row) => row?.month === month) + if (existing?.status === 'ACTIVE' && Number(existing?.usdToCnyAverage) > 0) return month + if (existing) { + blocker(context, '尾程费用业务月汇率', `业务月${month}汇率存在但不是ACTIVE,拒绝覆盖既有主数据`, listed.response) + return null + } + const created = await api(context, 'POST', '补齐尾程费用业务月汇率', + '/api/platform/v1/last-leg/master-data/exchange-rates', { + month, usdToCnyAverage: 7.1, remark: `${context.marker} acceptance rate`, + }) + context.assert(MODULE, '尾程费用业务月汇率可用于USD兑CNY', created.response, + created.data?.month === month && created.data?.status === 'ACTIVE' + && Number(created.data?.usdToCnyAverage) > 0, + `month=${month}, status=${created.data?.status || 'missing'}`, OPTIONS) + return created.data?.status === 'ACTIVE' ? month : null +} async function verifyReceivingWarehouseChange(context, producer, details) { const bookingId = id(producer.containerBookingId) if (!bookingId) { @@ -168,11 +188,13 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { { orderIds: allIds, palletMergeRequired: false }) context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS) + const feeMonth = await ensureCurrentUsdRate(context) + if (!feeMonth) return false const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) const fee = await api(context, 'POST', '新增本轮尾程订单费用', query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), { feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE', - currency: 'USD', amount: 0.01, businessDate: new Date().toISOString().slice(0, 10) }, + currency: 'USD', amount: 0.01, businessDate: `${feeMonth}-01` }, { 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) }) context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem From 71604c9c3936148baafd5e88338882acb17fd2be Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 20:00:44 +0800 Subject: [PATCH 34/42] fix: deliver actual destuff pallet fees --- .../inboundplan/service/DestuffingPalletCostService.java | 6 ++++-- .../service/DestuffingPalletCostServiceTest.java | 8 ++++++++ ...ase4-overseas-wallet-and-pallet-cost-contract.test.mjs | 2 +- .../phase4-overseas-wallet-and-pallet-cost.mjs | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) 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 946ded305..e491cbd24 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 @@ -22,7 +22,9 @@ import java.util.Map; @Service @RequiredArgsConstructor public class DestuffingPalletCostService { + static final String DESTUFF_BUSINESS_TYPE = "OVERSEAS_CONTAINER"; static final String READY_BOOKINGS_SQL = "SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MAX(t.completed_at) DESC LIMIT 50"; + static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id"; private final JdbcTemplate jdbc; private final PalletPhysicalGateway pallets; private final OverseasFeeSourceMapper feeSources; @@ -38,7 +40,7 @@ public class DestuffingPalletCostService { if (exists != null && exists > 0) return; List> plans = jdbc.queryForList("SELECT p.id,p.customer_id,p.container_no,p.overseas_warehouse_id,t.status FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id LEFT JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id WHERE o.source_container_booking_id=? ORDER BY p.customer_id,p.id", bookingId); if (plans.isEmpty() || plans.stream().anyMatch(row -> !"COMPLETED".equals(row.get("status")))) return; - List palletIds = jdbc.queryForList("SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL ORDER BY pp.warehouse_pallet_id", Long.class, bookingId); + List palletIds = jdbc.queryForList(RECEIVED_PALLET_IDS_SQL, Long.class, bookingId); BigDecimal totalCost = BigDecimal.ZERO; for (Long palletId : palletIds) totalCost = totalCost.add(pallets.resolve(palletId).unitPrice()); totalCost = totalCost.setScale(2, RoundingMode.HALF_UP); @@ -79,7 +81,7 @@ public class DestuffingPalletCostService { private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) { LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource(); v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId()); - v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType("CONTAINER_DESTUFFING"); + v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType(DESTUFF_BUSINESS_TYPE); 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java index 11eceedb6..1d6ae96db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java @@ -7,11 +7,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class DestuffingPalletCostServiceTest { + @Test void publishesDestuffingReceivableAsOverseasContainerBusiness() { + assertThat(DestuffingPalletCostService.DESTUFF_BUSINESS_TYPE).isEqualTo("OVERSEAS_CONTAINER"); + } @Test void newestCompletedBookingsAreNotStarvedByOldUnbillableRows() { assertThat(DestuffingPalletCostService.READY_BOOKINGS_SQL) .contains("ORDER BY MAX(t.completed_at) DESC LIMIT 50") .doesNotContain("ORDER BY MIN(t.completed_at)"); } + @Test void chargesOnlyPalletsWithPositiveReceivedPackagesAndVolume() { + assertThat(DestuffingPalletCostService.RECEIVED_PALLET_IDS_SQL) + .contains("pp.actual_package_count>0") + .contains("pp.actual_volume_cbm>0"); + } @Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() { List result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"), List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))); diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index e774e5218..c88834fbb 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -51,7 +51,7 @@ test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () '/api/platform/v1/pallets/summary', '/api/platform/v1/warehouse-admin/pallets/summary', '/pallets/summary?page=1&size=20', - 'CONTAINER_DESTUFFING', + 'OVERSEAS_CONTAINER', 'DESTUFF_PALLET', 'expectedPalletCost', 'sourceFeeId', diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index f5b32e584..ab094501e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -283,7 +283,7 @@ async function verifyDestuffCosts(context, completedFacts) { for (const fact of completedFacts) { const businessNo = `DESTUFF-${fact.containerBookingId}` const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { - businessType: 'CONTAINER_DESTUFFING', businessNo, + businessType: 'OVERSEAS_CONTAINER', businessNo, })), (response) => success(response) && rows(response.body.data).length > 0, 75) const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : [] const allocated = money(sources.reduce((sum, item) => sum + Number(item.amount || 0), 0)) From eac6875877b9119e25904c3133e0b1d727835d21 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 20:36:53 +0800 Subject: [PATCH 35/42] test: stabilize full dev acceptance orchestration --- scripts/admin-test-env-acceptance.mjs | 10 ++- .../inbound-fees-contract.test.mjs | 2 + .../admin-test-env-modules/inbound-fees.mjs | 3 +- .../inbound-measurement.mjs | 4 +- ...manual-state-loading-pda-contract.test.mjs | 17 ++-- .../manual-state-loading-pda.mjs | 79 +++++-------------- .../manual-state-matrix-contract.test.mjs | 10 +++ .../master-data-version-contract.test.mjs | 6 ++ .../admin-test-env-modules/master-data.mjs | 8 +- .../phase3-container-overview.mjs | 13 +-- ...se3-container-regression-contract.test.mjs | 6 ++ .../phase3-order-splitting-contract.test.mjs | 11 +++ .../phase3-order-splitting.mjs | 9 +++ ...-phase4-producer-fixture-contract.test.mjs | 3 +- .../phase3-warehouse-rbac.mjs | 4 +- ...canonical-warehouse-rbac-contract.test.mjs | 4 +- 16 files changed, 104 insertions(+), 85 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index fea49054c..5b55ff983 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -69,7 +69,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], - ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], + ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], @@ -1572,7 +1572,7 @@ export class AcceptanceRun { await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true) } if (Number(readiness().fbaContainerCount || 0) < 2) { - await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false) + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true) } if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) @@ -1583,11 +1583,15 @@ export class AcceptanceRun { const matrix = this.createdResources.phase4InboundProducerFixtures const containers = Array.isArray(matrix?.containers) ? matrix.containers : [] const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA' + && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' + && String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:')) const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') - const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY'] + const awaitingVariants = containers.filter((item) => ['SECONDARY'] .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`))) const shippingMatrixReady = dropshipReady?.shippingReady === true && Boolean(dropshipReady?.carrierScheduleQueryId) + && standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId) && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) && awaitingVariants.length >= 1 && awaitingVariants.every((item) => item.shippingReady === false diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs index 7c4bc44a5..d73d32dc8 100644 --- a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs @@ -45,6 +45,8 @@ test('automatic fee adjustment confirms the ten percent tolerance boundary', asy test('customer confirms chargeable quantity and manual unit price in one locked version', async () => { const source = await readFile(moduleFile, 'utf8') + assert.match(source, /const orderId = id\(fixture\?\.orderId\)/) + assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/) assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/) assert.match(source, /Client 一次确认计费量、对客单价和费用/) assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs index 7b81403ee..be615f844 100644 --- a/scripts/admin-test-env-modules/inbound-fees.mjs +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -131,7 +131,7 @@ export async function run(context) { && money(published?.amount) === money(sent?.totalAmount), `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) - const orderId = id(context.createdResources.orders?.orderId) + const orderId = id(fixture?.orderId) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { loginId: context.options.merchantUsername, password: context.options.merchantPassword, }, '') @@ -260,6 +260,7 @@ export async function run(context) { receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0, `receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS) + if (!customerConfirmed?.customerConfirmationStatus) { finish(context, start); return } context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId, freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id), manualVersion: manualV2.sourceFeeVersion, customerConfirmationStatus: customerConfirmed.customerConfirmationStatus } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 403c03a5e..1326096c1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1097,7 +1097,9 @@ export async function run(context) { context.createdResources.inboundMeasurement = { sealedCartonMarks, sealedSourcePalletNos, - marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), + marker: context.marker, tenantId, inboundId, + orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId), + measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg, diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 8443cfcd4..788d65410 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -57,17 +57,16 @@ test('fails closed when invoked outside the manual matrix wrapper', () => { assert.match(source, /missing=manualStateMatrix\.pages/) }) -test('covers clearance through its public API and keeps customs on the dedicated customs-v1 flow', () => { - assert.match(source, /new FormData\(\)/) - assert.match(source, /form\.append\('file'/) - assert.match(source, /form\.append\('expectedVersion'/) - assert.match(source, /form\.append\('operationReason'/) - assert.match(source, /'Idempotency-Key'/) - assert.match(source, /clearancePath\}\/document\/upload/) - assert.match(source, /clearancePath\}\/complete/) +test('reuses the departed-container clearance action verified by the dedicated module', () => { + assert.match(source, /createdResources\?\.phase3ContainerDocuments/) + assert.match(source, /documentIds\?\.DESTINATION_CLEARANCE/) + assert.match(source, /本轮离仓后已完成的目的港清关动作/) + assert.match(source, /GET', `\$\{CONTAINERS\}\/\$\{containerId\}\/clearance`/) + assert.match(source, /completed\?\.documentType === 'DESTINATION_CLEARANCE'/) + assert.match(source, /Boolean\(completed\?\.completedAt\)/) assert.match(source, /matrix\.pages\.containerLoading\.actions/) assert.doesNotMatch(source, /CUSTOMS_DECLARATION|loading-customs|customsPath/) assert.match(source, /tasks\/\$\{taskId\}\/evidence/) assert.doesNotMatch(source, /internal\/file|business-files/) - assert.doesNotMatch(source, /\{\s*fileId\s*:/) + assert.doesNotMatch(source, /clearance\/document\/upload/) }) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 6a2a139dc..1255cc298 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -19,37 +19,6 @@ function operationId(context, label) { return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128) } -async function post(context, path, body, label) { - return context.request('POST', path, body, context.token, - { 'Idempotency-Key': operationId(context, label) }) -} - -async function uploadDocument(context, path, expectedVersion, operationReason, label) { - const form = new FormData() - form.append('file', new Blob([`${context.marker}-${label}`], { type: 'text/plain' }), `${label}.txt`) - form.append('expectedVersion', String(expectedVersion)) - if (operationReason) form.append('operationReason', operationReason) - try { - const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', - headers: { - Accept: 'application/json', Authorization: `Bearer ${context.token}`, - 'Idempotency-Key': operationId(context, `${label}-upload`), - }, - body: form, - signal: AbortSignal.timeout(60_000), - }) - const raw = await response.text() - let body = null - try { body = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method: 'POST', path, http: response.status, body, raw: body ? null : raw.slice(0, 500), - requestId: body?.requestId || response.headers.get('x-request-id'), - traceId: body?.traceId || response.headers.get('x-trace-id') } - } catch (error) { - return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } - } -} - async function ensureCompletionEvidence(context, chain, label) { const taskId = id(chain.task?.id) const warehouseId = id(chain.source?.warehouseId) @@ -351,34 +320,26 @@ function fixture(value, status, detailPath, extra = {}) { return { id: id(value), status, detailPath, ...extra } } -async function completeSimpleDocuments(context, chain, matrix) { - const reason = `${context.marker} 人工页面动作验收` - const clearancePath = `${CONTAINERS}/${chain.containerId}/clearance` - const clearanceUpload = await uploadDocument(context, `${clearancePath}/document/upload`, 0, - reason, 'container-clearance') - const clearanceDocument = context.apiSuccess(MODULE, '货柜公开接口上传目的港清关资料', - clearanceUpload, '', OPTIONS) - context.assert(MODULE, '货柜清关上传返回真实文件事实', clearanceUpload, - Boolean(clearanceDocument?.id) && Boolean(clearanceDocument?.fileId) - && clearanceDocument?.documentType === 'DESTINATION_CLEARANCE' - && /^[0-9a-f]{64}$/i.test(String(clearanceDocument?.fileHash || '')) - && !clearanceDocument?.completedAt, - `documentId=${id(clearanceDocument?.id) || 'missing'}, version=${clearanceDocument?.version ?? 'missing'}`, OPTIONS) - if (!clearanceDocument?.id || !clearanceDocument?.fileId || !Number.isInteger(Number(clearanceDocument?.version))) return - const clearanceCompleteResponse = await post(context, `${clearancePath}/complete`, { - expectedVersion: Number(clearanceDocument?.version), operationReason: reason, - }, 'container-clearance-complete') - const clearanceCompleted = context.apiSuccess(MODULE, '货柜点击完成目的港清关', - clearanceCompleteResponse, '', OPTIONS) - context.assert(MODULE, '货柜清关完成事实可回读', clearanceCompleteResponse, - id(clearanceCompleted?.id) === id(clearanceDocument?.id) && Boolean(clearanceCompleted?.completedAt) - && Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1, - `documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS) +async function registerClearanceAction(context, matrix) { + const documentsFixture = context.createdResources?.phase3ContainerDocuments + const containerId = id(documentsFixture?.containerBookingId) + const expectedDocumentId = id(documentsFixture?.documentIds?.DESTINATION_CLEARANCE) + context.assert(MODULE, '复用本轮离仓后已完成的目的港清关动作', null, + documentsFixture?.marker === context.marker && Boolean(containerId && expectedDocumentId), + `containerBookingId=${containerId || 'missing'}, documentId=${expectedDocumentId || 'missing'}`, OPTIONS) + if (!containerId || !expectedDocumentId || documentsFixture?.marker !== context.marker) return - matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED', - `/container-loading/${chain.containerId}/smart-loading`, { - documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version), - completedAt: clearanceCompleted?.completedAt || null, + const response = await context.request('GET', `${CONTAINERS}/${containerId}/clearance`) + const completed = context.apiSuccess(MODULE, '回读目的港清关动作验收事实', response, '', OPTIONS) + context.assert(MODULE, '目的港清关动作保持完成和版本事实', response, + id(completed?.id) === expectedDocumentId && completed?.documentType === 'DESTINATION_CLEARANCE' + && Boolean(completed?.completedAt) && Number.isInteger(Number(completed?.version)), + `documentId=${id(completed?.id) || 'missing'}, completedAt=${completed?.completedAt || 'missing'}, version=${completed?.version ?? 'missing'}`, OPTIONS) + + matrix.pages.containerLoading.actions = { clearance: fixture(containerId, 'COMPLETED', + `/container-loading/${containerId}/smart-loading`, { + documentId: id(completed?.id), documentVersion: Number(completed?.version), + completedAt: completed?.completedAt || null, }) } } @@ -464,6 +425,6 @@ export async function run(context, matrix = context.createdResources.manualState id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED', `planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS) - await completeSimpleDocuments(context, completed, matrix) + await registerClearanceAction(context, matrix) } import { iso6346ContainerNumber } from './iso6346-container-number.mjs' diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 29201174a..94b20a224 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -26,6 +26,11 @@ test('focused matrix execution injects real container lifecycle prerequisites an 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', + 'phase3-port-schedules', + 'truck-appointments', + 'service-center-flow', + 'phase3-outbound-dispatch', + 'phase3-container-documents', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) @@ -47,6 +52,11 @@ test('focused splitting and manual groups resolve producers before consumers wit 'phase3-loading-finalize', 'phase3-order-splitting', 'phase3-customs', + 'phase3-port-schedules', + 'truck-appointments', + 'service-center-flow', + 'phase3-outbound-dispatch', + 'phase3-container-documents', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder([ diff --git a/scripts/admin-test-env-modules/master-data-version-contract.test.mjs b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs index 6b2f0d5f6..637964831 100644 --- a/scripts/admin-test-env-modules/master-data-version-contract.test.mjs +++ b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs @@ -10,3 +10,9 @@ test('non-versioned master data stays explicit without making the full gate perm assert.match(source, /非版本化主数据不伪造旧version请求/) assert.match(source, /phase4-overseas-master-data/) }) + +test('warehouse import fixture tracks the current version two template and internal code column', () => { + assert.match(source, /仓库编码\*', '内部编号', '仓库名称/) + assert.match(source, /sheet: '仓库', templateVersion: '2'/) + assert.match(source, /xlsxBytes\(fixture\.headers, fixture\.values, fixture\.sheet, fixture\.templateVersion\)/) +}) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index bfe308b76..8a2760e92 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -31,8 +31,8 @@ function u16(value) { return Uint8Array.from([value & 255, (value >>> 8) & 255]) function u32(value) { return Uint8Array.from([value & 255, (value >>> 8) & 255, (value >>> 16) & 255, (value >>> 24) & 255]) } function concat(...parts) { const result = new Uint8Array(parts.reduce((sum, item) => sum + item.length, 0)); let offset = 0; for (const part of parts) { result.set(part, offset); offset += part.length } return result } function xmlEscape(value) { return String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') } -function xlsxBytes(headers, values, sheetName) { - const rows = [['模板版本', '1'], ['说明', 'Admin HTTP acceptance fixture'], headers, values] +function xlsxBytes(headers, values, sheetName, templateVersion = '1') { + const rows = [['模板版本', templateVersion], ['说明', 'Admin HTTP acceptance fixture'], headers, values] const cells = rows.map((row, rowIndex) => `${row.map((value, columnIndex) => `${xmlEscape(value)}`).join('')}`).join('') const files = { '[Content_Types].xml': '', @@ -189,7 +189,7 @@ export async function run(context) { ['往来单位', '/api/platform/v1/partners/import-template'], ] const importFixtures = { - 仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' }, + 仓库: { headers: ['仓库编码*', '内部编号', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, '', `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库', templateVersion: '2' }, 快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' }, 往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '船司标准编码', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'MATSON', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' }, } @@ -199,7 +199,7 @@ export async function run(context) { `bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`) if (xlsxOk(response)) { const fixture = importFixtures[name] - const fixtureBytes = xlsxBytes(fixture.headers, fixture.values, fixture.sheet) + const fixtureBytes = xlsxBytes(fixture.headers, fixture.values, fixture.sheet, fixture.templateVersion) const importPath = `${path.replace('/import-template', '/import')}?tenantId=0` const first = await uploadTemplate(context, importPath, fixtureBytes, `${context.marker}-${name}.xlsx`) const firstData = first.body?.data diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 3baabdd5d..503ddfe26 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -94,14 +94,17 @@ export async function run(context) { for (const documentType of ['DELIVERY_ORDER']) { const response = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/documents/${documentType}`) - const data = context.apiSuccess(MODULE, `查询 ${documentType} 单证`, response, `documentType=${documentType}`, OPTIONS) - context.assert(MODULE, `${documentType} 单证归属货柜`, response, - data == null || id(data.containerId || containerId) === containerId, - `containerBookingId=${containerId}`, OPTIONS) + context.assert(MODULE, `国内未出仓时 ${documentType} 不生成国外工作台数据`, response, + response.http === 200 && String(response?.body?.code) === 'A0404' + && String(response?.body?.message || '').includes('国外工作台货柜不存在'), + `HTTP=${response.http}, code=${response?.body?.code ?? 'missing'}, documentType=${documentType}`, OPTIONS) } const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`) - context.apiSuccess(MODULE, '查询目的港清关资料', clearance, `containerBookingId=${containerId}`, OPTIONS) + context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance, + clearance.http === 200 && String(clearance?.body?.code) === 'A0404' + && String(clearance?.body?.message || '').includes('国外工作台货柜不存在'), + `HTTP=${clearance.http}, code=${clearance?.body?.code ?? 'missing'}, containerBookingId=${containerId}`, OPTIONS) const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`) if (fixture?.finalStatus === 'LOADED') { context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 160033774..ff083903f 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -52,6 +52,12 @@ test('overview regression requires workflowStatus and distinguishes 100 percent assert.doesNotMatch(matrix, /PDA overview `workflowStatus` 缺失[\s\S]*BLOCKED_CONTRACT/) }) +test('pre-departure overview rejects destination workbench documents explicitly', () => { + assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/) + assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/) + assert.match(overviewAcceptance, /国外工作台货柜不存在/) +}) + test('active overview photos download through controlled paths while void history has no download entry', () => { assert.match(overviewOpenApi, /\/api\/platform\/v1\/containers\/\{id\}\/pda\/photos\/\{fileId\}\/download/) assert.match(overviewAcceptance, /\['evidenceId', 'fileId', 'status'\]\.every/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 96a8a44ec..14dc2b700 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -64,6 +64,17 @@ test('PDA opens only after linking a released requirement and uses stable plan i assert.doesNotMatch(source, /push-preload/) }) +test('plan confirmation prepares the remainder initial fee first', () => { + const feeIndex = source.indexOf('ensureInitialCalculated(context') + const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`") + assert.match(source, /import \{ ensureInitialCalculated \} from '\.\/inbound-fees\.mjs'/) + assert.ok(feeIndex >= 0 && confirmIndex > feeIndex) + assert.match(source, /inboundId: id\(candidate\.inboundId\)/) + assert.match(source, /finalStatus: 'APPROVED'/) + assert.match(source, /processStatus: 'WAIT_SPLIT'/) + assert.match(source, /if \(!initialFee\) return null/) +}) + test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => { const claimIndex = source.indexOf("`${PDA}/tasks/${taskId}/claim`") const snapshotIndex = source.indexOf('inventory-snapshot?warehouseId=') diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index b4249f523..b57902ad2 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -9,6 +9,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs' +import { ensureInitialCalculated } from './inbound-fees.mjs' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } @@ -242,6 +243,14 @@ async function loadOneFragment(context, state) { `orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundId || !candidate?.inboundDetailId) return null + const initialFee = await ensureInitialCalculated(context, { + tenantId, + inboundId: id(candidate.inboundId), + finalStatus: 'APPROVED', + processStatus: 'WAIT_SPLIT', + }, OPTIONS) + if (!initialFee) return null + const createPlan = await request(context, 'GET', `${SMART}/plans/by-booking/${containerId}`) const plan = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 模拟柜自动创建的装柜计划`, createPlan, '', OPTIONS) const planId = id(plan?.id) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index e7129fd93..83d107f6c 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -118,7 +118,7 @@ test('measurement and loading pass destination identity into the LOADED producer test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) - assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) const start = entry.indexOf('if (pickupRequired && producerKey)') const end = entry.indexOf('} finally {', start) @@ -147,6 +147,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /overseasReady\?\.shippingReady === true/) assert.match(entry, /awaitingVariants\.length >= 1/) assert.doesNotMatch(entry, /awaitingVariants\.length === 2/) + assert.match(entry, /standardFbaReady\?\.shippingReady === true/) assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) }) diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index dd09e2b4c..e112f93b4 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -24,8 +24,10 @@ const PHASE5_OPERATOR_CODES = [ 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', ] @@ -88,7 +90,7 @@ export async function run(context) { context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse, operatorCodes.has('platform:firstleg:inbound:loading:view') && operatorCodes.has('platform:firstleg:inbound:loading:edit') - && operatorCodes.size === 29 + && operatorCodes.size === 31 && PHASE4_READ_CODES.every((code) => operatorCodes.has(code)) && PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code)) && !operatorCodes.has('platform:firstleg:inbound:loading:complete') diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index d3997a36c..8bdd57e31 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -26,7 +26,7 @@ test('warehouse operator and tally use the exact phase-four and phase-five permi assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`)) assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`)) } - assert.match(acceptance, /operatorCodes\.size === 29/) + assert.match(acceptance, /operatorCodes\.size === 31/) assert.match(acceptance, /tallyCodes\.size === 27/) assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/) assert.match(iamSql, /assert_phase4_warehouse_role_minimum/) @@ -47,8 +47,10 @@ test('warehouse roles include the exact phase-five fulfillment responsibilities' 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit' ]) { From 6737c4c3271aa74d5b23320cc6ddb9b9e3474092 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 20:53:24 +0800 Subject: [PATCH 36/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 --- .../PlatformInboundMeasurementController.java | 4 +- .../service/CartonMeasurementService.java | 4 +- .../firstleg/service/FbaPricingService.java | 19 +- .../InboundMeasurementOpenApiService.java | 3 +- .../service/InboundMeasurementService.java | 8 +- .../service/InboundOrderCreationService.java | 20 +- .../service/MeasurementBatchService.java | 2 +- .../service/MeasurementReviewService.java | 3 + .../main/resources/openapi/firstleg-v1.yaml | 8 +- .../service/FbaPricingServiceTest.java | 55 ++++- .../InboundOrderCreationServiceTest.java | 20 ++ .../service/MeasurementReviewServiceTest.java | 16 ++ .../service/OrderApplicationService.java | 34 ++- .../service/OrderInboundOutboxPublisher.java | 9 +- .../service/UnifiedOrderQueryService.java | 29 ++- .../OrderApplicationServiceStateTest.java | 27 ++ .../OrderInboundOutboxPublisherTest.java | 16 ++ .../service/UnifiedOrderQueryServiceTest.java | 6 +- scripts/AGENTS.md | 2 +- scripts/README.md | 2 +- scripts/real-order-xlsx-import.md | 18 +- scripts/real-order-xlsx-test-env.mjs | 232 +++++++++++++----- scripts/real-order-xlsx-test-env.test.mjs | 108 +++++++- 23 files changed, 525 insertions(+), 120 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 569cfa5e5..a7196bb7b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -79,7 +79,7 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") - @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + @Operation(summary = "保存测量草稿", description = "订单待审核但货物已到仓时可先保存;也可在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。") @PutMapping("/measurement/draft") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," @@ -96,7 +96,7 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认测量并提交审核") @Operation(summary = "确认测量并提交审核", - description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。") + description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING;订单未审核时只保留待审核测量,不能审核通过或进入后续履约。") @PostMapping("/measurement/submit") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')") public ApiResponse submit( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 7ab22451a..9e8086042 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -34,9 +34,9 @@ import java.util.Set; public class CartonMeasurementService { private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class); private static final Set DEVICE_WRITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set MANUAL_COUNT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 633c88542..ace5297cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -219,6 +219,7 @@ public class FbaPricingService { /** * 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。 * 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。 + * 已发送或客户已确认的费用继续参与柜级计费量聚合,但自身快照保持不变。 */ @Transactional public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees( @@ -257,19 +258,22 @@ public class FbaPricingService { notReady = true; continue; } - if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) { - throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用"); - } - if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) { + boolean locked = List.of("CONFIRMED", "SENT").contains(current.getFeeStatus()) + || current.getCustomerConfirmedAt() != null; + if (!locked && !"CALCULATED".equals(current.getFeeStatus())) { throw conflict("柜内费用状态已变化,不能重算阶梯费用"); } BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量"); BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积"); members.add(new ContainerFeeMember(inbound, current, weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP), - chargeableWeight(weight, volume))); + chargeableWeight(weight, volume), locked)); } if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0); + List unlockedMembers = members.stream().filter(member -> !member.locked()).toList(); + if (unlockedMembers.isEmpty()) { + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0); + } InboundPricingIdempotencyEntity token = beginContainerRecalculation( tenantId, containerBookingId, key, normalizedTrigger); if ("COMPLETED".equals(token.getStatus())) { @@ -281,7 +285,7 @@ public class FbaPricingService { .map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity())) .toList(); List quotes = new ArrayList<>(); - for (ContainerFeeMember member : members) { + for (ContainerFeeMember member : unlockedMembers) { PricingPolicy policy = pricingPolicy(member.inbound(), now); ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(), @@ -371,7 +375,8 @@ public class FbaPricingService { } private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current, - BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { } + BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity, + boolean locked) { } private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy, ActivePriceBookEstimator.Estimate estimate) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java index 594ddf8a6..5e705112b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -47,7 +47,8 @@ import java.util.Set; public class InboundMeasurementOpenApiService { private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class); static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements"; - private static final Set ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED"); + private static final Set ACCEPTABLE_INBOUND_STATUSES = Set.of( + "PREPARING", "WAIT_MEASURE", "MEASURE_REJECTED"); private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 66c4c636a..6e15a20d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -49,9 +49,9 @@ public class InboundMeasurementService { /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); @@ -167,7 +167,7 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") + if (!Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") .contains(inbound.getProcessStatus())) { throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果"); } @@ -384,7 +384,7 @@ public class InboundMeasurementService { inbound.setActualGrossWeightKg(totals.grossWeightKg()); inbound.setActualNetWeightKg(totals.netWeightKg()); inbound.setPalletCount(totals.palletCount()); - if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { + if (Set.of("PREPARING", "WAIT_MEASURE").contains(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } inbound.setUpdatedBy(operatorId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index e0db31b88..350edfe28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -40,7 +40,7 @@ public class InboundOrderCreationService { long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now()); linkTask(existing, task.getId()); - upgradePreparing(existing, command); + approvePreparedInbound(existing, command); savePickupSnapshot(existing.getId(), command, LocalDateTime.now()); return inboundId; } @@ -132,7 +132,7 @@ public class InboundOrderCreationService { } LocalDateTime now = LocalDateTime.now(); - InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT"); + InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "SUBMITTED"); try { orders.insert(inbound); savePickupSnapshot(inbound.getId(), command, now); @@ -153,6 +153,7 @@ public class InboundOrderCreationService { .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) .eq(InboundOrderEntity::getId, existing.getId()) .eq(InboundOrderEntity::getProcessStatus, "PREPARING") + .set(InboundOrderEntity::getOrderStatusSnapshot, "SUBMITTED") .set(InboundOrderEntity::getOrderNo, command.orderNo()) .set(InboundOrderEntity::getCustomerId, command.customerId()) .set(InboundOrderEntity::getCustomerName, command.customerName()) @@ -175,13 +176,16 @@ public class InboundOrderCreationService { .setSql("version = version + 1")); } - private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) { - if (!"PREPARING".equals(existing.getProcessStatus())) return; + private void approvePreparedInbound(InboundOrderEntity existing, OrderApprovedInboundCommand command) { + String currentStatus = existing.getProcessStatus(); + if (currentStatus == null || !java.util.Set.of("PREPARING", "MEASURING", "MEASURE_PENDING") + .contains(currentStatus)) return; + String approvedStatus = "PREPARING".equals(currentStatus) ? "WAIT_MEASURE" : currentStatus; var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) .eq(InboundOrderEntity::getId, existing.getId()) - .eq(InboundOrderEntity::getProcessStatus, "PREPARING") - .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") + .eq(InboundOrderEntity::getProcessStatus, currentStatus) + .set(InboundOrderEntity::getProcessStatus, approvedStatus) .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) @@ -189,12 +193,12 @@ public class InboundOrderCreationService { .setSql("version = version + 1"); if (orders.update(null, update) != 1) { InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true); - if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) { + if (current == null || !"APPROVED".equals(current.getOrderStatusSnapshot())) { throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突"); } return; } - addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE", + addHistory(existing.getId(), existing.getTenantId(), currentStatus, approvedStatus, "ORDER_APPROVED", LocalDateTime.now()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index bad5a21ad..b2345e2b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -43,7 +43,7 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; private static final Set EDITABLE_INBOUND_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index ecfe7c525..54175a32a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -64,6 +64,9 @@ public class MeasurementReviewService { InboundOrderEntity order = requireOrder(tenantId, inboundId); requirePendingReview(order); + if (!"APPROVED".equals(order.getOrderStatusSnapshot())) { + throw new BizException(ResultCode.CONFLICT, "订单尚未审核通过,已到仓测量数据可保留但暂不能审核"); + } InboundMeasurementEntity measurement = repository.findMeasurement( tenantId, inboundId, request.measurementId()); if (measurement == null) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 51f3c9c4e..fcf2441e8 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -950,8 +950,8 @@ paths: summary: 保存测量草稿 description: >- 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; - actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING - 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 PREPARING(订单待审核但已到仓)、 + WAIT_MEASURE、MEASURING 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 measurement/items/{itemId}/confirmed-package-count 明细专用接口。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1154,7 +1154,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 + description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1175,7 +1175,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 - description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。 + description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 9bede13f7..407eded52 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -788,7 +788,7 @@ class FbaPricingServiceTest { InboundFeeEntity first = savedFee("CALCULATED"); first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000")); first.setMeasurementId(301L); first.setMeasurementSource("OUTER"); - InboundFeeEntity second = savedFee("CONFIRMED"); + InboundFeeEntity second = savedFee("CALCULATED"); second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000")); second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L); second.setMeasurementSource("ACTUAL"); @@ -834,20 +834,61 @@ class FbaPricingServiceTest { } @Test - @DisplayName("柜内任一已发送或客户确认费用阻断整组重算") - void rejectsContainerRecalculationWhenAnyFeeIsImmutable() { + @DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算") + void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() { readyRepository(); - InboundPricingEntity inbound = inbound("100", "0.1"); + InboundPricingEntity lockedInbound = inbound("100", "0.1"); + InboundPricingEntity unlockedInbound = inbound("80", "0.1"); + unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002"); InboundFeeEntity sent = savedFee("SENT"); + sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1")); + InboundFeeEntity calculated = savedFee("CALCULATED"); + calculated.setId(502L); calculated.setInboundId(30002L); + calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00")))); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED); + assertThat(result.recalculatedCount()).isEqualTo(1); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getInboundId()).isEqualTo(30002L); + assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00"); + verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT"); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("柜内费用全部锁定时关系变更无需重算且不报错") + void skipsContainerRecalculationWhenEveryFeeIsImmutable() { + InboundPricingEntity inbound = inbound("100", "0.1"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( memberRow(901L, INBOUND, "100", "0.1") )); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed); - assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2")) - .isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认"); + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked"); + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE); + assertThat(result.recalculatedCount()).isZero(); verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString()); verify(repository, never()).insertFee(any()); verify(priceBookEstimator, never()).estimate(anyLong(), any()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 93c19b4b3..98e4b2f4f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -157,6 +157,26 @@ class InboundOrderCreationServiceTest { verify(orders, times(2)).update(any(), any(Wrapper.class)); } + @Test + void approvalKeepsMeasurementAlreadyCapturedBeforeOrderReview() { + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); + prepared.setProcessStatus("MEASURING"); + prepared.setOrderStatusSnapshot("SUBMITTED"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(prepared); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); + + verify(orders, times(2)).update(any(), any(Wrapper.class)); + ArgumentCaptor historyCaptor = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(histories).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getFromStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getToStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getAction()).isEqualTo("ORDER_APPROVED"); + } + private static InboundOrderEntity inbound(long id, String code, String feeStatus, Long carrierId, String carrierName) { InboundOrderEntity inbound = new InboundOrderEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 1452af2df..47d037ebd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -65,6 +65,21 @@ class MeasurementReviewServiceTest { verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); } + @Test + @DisplayName("订单待审核时保留测量数据但不能审核测量") + void shouldBlockMeasurementReviewUntilOrderIsApproved() { + Fixture fixture = fixture(false); + fixture.order.setOrderStatusSnapshot("SUBMITTED"); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-before-order", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单尚未审核通过"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + @Test @DisplayName("审核通过是测量到计费的唯一正常推进关口") void shouldApproveOnlyPendingSubmittedMeasurement() { @@ -249,6 +264,7 @@ class MeasurementReviewServiceTest { when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("MEASURE_PENDING"); + order.setOrderStatusSnapshot("APPROVED"); order.setMeasureReviewStatus("PENDING"); order.setTotalPackageCount(5); order.setActualGrossWeightKg(new BigDecimal("1787.500")); diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index bbc2390d9..de11b6b0b 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -187,7 +187,10 @@ public class OrderApplicationService { if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); - if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target); + if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order); + if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { + writeOutbox(order, target); + } } /** 提交订单的幂等入口。 */ @@ -668,9 +671,13 @@ public class OrderApplicationService { private void writeOutbox(OrderEntity order, OrderStatus target) { OutboxEventEntity event = new OutboxEventEntity(); event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId()); - event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED"); + event.setEventType(switch (target) { + case SUBMITTED -> "FIRST_LEG_ORDER_PREPARED"; + case APPROVED -> "FIRST_LEG_ORDER_APPROVED"; + default -> "FIRST_LEG_ORDER_CANCELLED"; + }); try { - event.setPayloadJson(target == OrderStatus.APPROVED + event.setPayloadJson(target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED ? json.writeValueAsString(inboundCommand(order, totalPackageCount(order))) : json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(), "orderNo", order.getOrderNo(), "status", target.name()))); @@ -699,10 +706,27 @@ public class OrderApplicationService { order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode()); } private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { - FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); - if (client == null || !StringUtils.hasText(internalToken)) return; int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount) .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); + prepareSubmittedInbound(order, packageCount); + } + + /** + * 提交即准备稳定的预入库单,供货物提前到仓时采集称重和箱规; + * 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。 + */ + private void prepareSubmittedInbound(OrderEntity order) { + long total = repository.findCargo(order.getTenantId(), order.getId()).stream() + .map(OrderCargoEntity::getPackageCount) + .filter(java.util.Objects::nonNull) + .mapToLong(Integer::longValue) + .sum(); + prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null); + } + + private void prepareSubmittedInbound(OrderEntity order, Integer packageCount) { + FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); + if (client == null || !StringUtils.hasText(internalToken)) return; FirstlegInboundClient.OrderApprovedInboundCommand command = inboundCommand(order, packageCount); Runnable prepare = () -> { try { diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index e933b22cc..67caa0a69 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; -/** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */ +/** 可靠投递订单预入库与审核事件;接收端以订单唯一键保证重复投递安全。 */ @Component @Slf4j public class OrderInboundOutboxPublisher { @@ -38,7 +38,8 @@ public class OrderInboundOutboxPublisher { publishDeclarationUpdate(event); continue; } - if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { + boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType()); + if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { failPermanently(event, "unsupported order event"); continue; } @@ -73,7 +74,9 @@ public class OrderInboundOutboxPublisher { } } try { - ApiResponse response = client.create(internalToken, command); + ApiResponse response = preparing + ? client.prepare(internalToken, command) + : client.create(internalToken, command); if (response == null || !response.successful()) { throw new IllegalStateException("头程入库服务返回失败"); } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 39caa2e28..e9bbf2a52 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -64,9 +64,7 @@ public class UnifiedOrderQueryService { .collect(Collectors.toMap(OrderEntity::getOrderNo, Function.identity(), (left, right) -> left)); List selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList(); List orderIds = selected.stream().map(OrderEntity::getId).toList(); - Map inboundByOrder = summaries(null, - selected.stream().filter(order -> order.getStatus() != OrderStatus.SUBMITTED) - .map(OrderEntity::getId).toList()); + Map inboundByOrder = summaries(null, orderIds); Map latestNotes = latestNotes(null, orderIds); Map cargoSummaries = cargoSummaries(null, orderIds); Map childCounts = childCounts(selected); @@ -86,9 +84,10 @@ public class UnifiedOrderQueryService { List statuses = "PENDING_REVIEW".equals(query.mainStatus()) ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; PageResult source = orders.unifiedPage(tenantId, query, statuses); + List sourceOrderIds = source.records().stream().map(OrderEntity::getId).toList(); Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) - ? Map.of() - : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + ? optionalSummaries(tenantId, sourceOrderIds) + : summaries(tenantId, sourceOrderIds); Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map cargoSummaries = cargoSummaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); @@ -117,7 +116,7 @@ public class UnifiedOrderQueryService { orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), orders.findHistory(tenantId, orderId)); FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED - ? null : firstlegDetail(tenantId, orderId); + ? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); String creatorName = orderDetail.statusHistory().stream() .filter(item -> "CREATE".equals(item.getAction())) @@ -192,6 +191,15 @@ public class UnifiedOrderQueryService { Function.identity(), (left, right) -> left)); } + private Map optionalSummaries(Long tenantId, List orderIds) { + try { + return summaries(tenantId, orderIds); + } catch (BizException ignored) { + // Order 待审核列表仍可独立使用;Firstleg 预入库投影恢复后再开放提前测量入口。 + return Map.of(); + } + } + private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) { FirstlegUnifiedOrderClient.Detail detail = requireNullable(call(() -> firstleg.detail(internalToken, new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); @@ -199,6 +207,15 @@ public class UnifiedOrderQueryService { return detail; } + + private FirstlegUnifiedOrderClient.Detail optionalFirstlegDetail(long tenantId, long orderId) { + try { + return firstlegDetail(tenantId, orderId); + } catch (BizException ignored) { + return null; + } + } + private List validateSummaries( List summaries, String message) { return summaries.stream().map(summary -> validateSummary(summary, message)).toList(); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index c959de48a..98f67b1e7 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -1,7 +1,9 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.dto.OrderRequests; @@ -19,6 +21,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.beans.factory.ObjectProvider; import java.math.BigDecimal; import java.time.LocalTime; @@ -272,6 +275,30 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(20002L, order); } + @Test + void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + FirstlegInboundClient client = mock(FirstlegInboundClient.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(client); + when(client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(service, "firstlegProvider", provider); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + + service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + + verify(client).prepare(eq("internal-token"), argThat(command -> command.orderId() == 1L + && command.totalPackageCount() == 1)); + } + @Test void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() { OrderRepository repository = mock(OrderRepository.class); diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index 33285db1b..a339603dd 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -23,6 +23,22 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class OrderInboundOutboxPublisherTest { + @Test + void matchingPreparedEventIsDeliveredAndPublished() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_PREPARED", 11L, 21L, + completePayload(11L, 21L, "BUY_DECLARATION"))); + when(fixture.client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.client).prepare(eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(command -> command.orderId() == 21L + && command.totalPackageCount() == 4)); + verify(fixture.client, never()).create(any(), any()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); + } + @Test void matchingApprovedEventIsDeliveredAndPublished() { Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L))); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index a7b18a898..41a61356f 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -81,7 +81,7 @@ class UnifiedOrderQueryServiceTest { assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); assertThat(row.relation()).isNull(); }); - verify(firstleg, never()).summaries(any(), any()); + verify(firstleg).summaries(eq("internal-token"), any()); } @Test @@ -439,7 +439,7 @@ class UnifiedOrderQueryServiceTest { } @Test - void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() { + void pendingReviewDetailUsesOrderInboundCodeWhenPreparingProjectionIsUnavailable() { OrderEntity order = order(OrderStatus.SUBMITTED); order.setInboundCode("RK-77"); when(orders.findOwned(10001L, 77L)).thenReturn(order); @@ -452,7 +452,7 @@ class UnifiedOrderQueryServiceTest { assertThat(result.summary().inboundCode()).isEqualTo("RK-77"); assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77"); - verify(firstleg, never()).detail(any(), any()); + verify(firstleg).detail(eq("internal-token"), any()); } @Test diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 616b60f4f..e8ffd0bba 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,7 +15,7 @@ | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | -| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 | +| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,支持预检、真实字段草稿、批量提交到待审核和少量完整测量到待分柜;待审核阶段不审核且不使用托盘/库位;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | | 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | diff --git a/scripts/README.md b/scripts/README.md index 3e75b558d..69aba35e5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -69,7 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | -| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` | +| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | | 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 | diff --git a/scripts/real-order-xlsx-import.md b/scripts/real-order-xlsx-import.md index e0c256035..bf6a9d56b 100644 --- a/scripts/real-order-xlsx-import.md +++ b/scripts/real-order-xlsx-import.md @@ -17,13 +17,17 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" - `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。 - `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。 - `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。 -- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。 -- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。 +- `--stage pending-review --allow-test-placeholders true --limit 1` 创建订单并由客户提交,回查客户状态 `SUBMITTED` 和统一状态 `PENDING_REVIEW` 后停止;不审核、不生成入库投影,不调用箱码、测量、称重、托盘或库位接口。这是源订单批量造数的默认推荐阶段。试价返回“无匹配报价”或“未达到最低重量阶梯”时只记录 `pricingWarning`,仍创建并提交到待审核,交由人工审核处理。 +- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只用于少量完整链验收,不用于大批量造数。 +- `--limit` 是本次新增尝试数,默认 1;实际处理方式由 `--concurrency` 决定。前三票连续失败且没有成功时停止扩大写入。 +- `--concurrency` 是单进程并发票数,默认 1、最大 10。所有并发票共享一个带串行落盘队列的 `state.json` 和一个批次锁,不得再启动多个进程。测试环境实测 3 路可稳定运行;提高并发前必须重新小批压测,出现服务端 `S0500` 时降低并发,不能用失败率换速度。 ## 源数据与模拟规则 同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。 +订单表持久精度为毛重 3 位小数、体积 4 位小数。拆分行先按源实际值求和,再统一按该精度四舍五入后创建和回查;不逐行先取整,避免分摊尾差导致总量漂移。 + 报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。 试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。 @@ -38,20 +42,24 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" 源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。 -照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 +产品照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。同一批次只上传一次产品占位图并在订单中复用;箱级测量图和每托称重图仍逐条上传作为正式流程要求的证据。客户箱规每次进程启动只查询一次。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 -托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 +测量草稿是带版本的幂等更新。仅当该接口返回服务端 `S0500` 时,脚本最多重读两次最新测量版本后重试,并把每次 requestId/traceId 记入订单检查点;业务校验错误、其他接口错误和第三次失败均原样终止该票,不无限重试。 + +只有 `wait-split` 阶段会查询并复用 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。`pending-review` 完全不查询或占用托盘、库位。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 ## 报告与重跑 必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。 -同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。 +同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`pending-review` 仅安全恢复停在试价阶段、尚未创建订单的 `A0404/A0422`,旧错误保留在 `priorAttempts`。载荷规则发生变化后必须使用新的 batch-id 和输出目录,不能用旧创建幂等键发送不同载荷。`limit` 每次从尚未尝试或上述可恢复票继续;不是通用失败恢复器。特别注意:不同阶段不能用同一批状态自动推进已有订单;需使用全新批次选择尚未尝试票。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。任一并发票返回 `A0401` 时,本批最多完成当前并发组便立即熔断,防止令牌失效污染后续订单。批次锁记录进程 PID;异常终止后,下次启动仅在该 PID 已不存在时清理精确的陈旧锁。 源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。 用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。 +清库后的批量待审核运行固定使用 `--stage pending-review --allow-test-placeholders true --concurrency 10 --limit 10000`。先确认 IT测试客户、义乌仓、可选渠道与目的仓主档已经初始化;报价缺失不阻断待审核创建。运行完成以 `preflight.matched = state.orders`、全部检查点无 `STARTED`、成功项均为 `PENDING_REVIEW` 为完成口径。 + ## 本地检查 ```powershell diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs index 9605eec49..7cf881ead 100644 --- a/scripts/real-order-xlsx-test-env.mjs +++ b/scripts/real-order-xlsx-test-env.mjs @@ -2,7 +2,7 @@ // Explicitly authorized non-production HTTP import. Never resets a database. import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { mkdir, readFile, writeFile, rename, open } from 'node:fs/promises' +import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' @@ -45,7 +45,7 @@ export function normalizeRows(rows) { for (const name of ['实际件数', '实际毛重', '实际立方']) { if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`) } - const sum = (name) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0)) + const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits) const packageCount = sum('实际件数') if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT') if (entries.length > 1) { @@ -68,7 +68,10 @@ export function normalizeRows(rows) { sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel), destination, destinationType, pricingTaxMode, taxMode, chargeMode, fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', - packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'), + // Match the persisted order schema: kg has 3 decimals and CBM has 4. + // Split source rows can contain longer allocation decimals whose exact sum + // cannot be represented by the order table. + packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4), cargoName: distinct('品名').filter(Boolean).join(' / '), sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'), referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems } @@ -199,7 +202,7 @@ export function requireSuccess(response) { async function main() { const args = new Map() - const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) + const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) for (let i = 2; i < process.argv.length; i += 2) { const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1] if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`) @@ -207,10 +210,14 @@ async function main() { } for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`) const stage = args.get('stage') || 'preflight' - if (!['preflight', 'create', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split') - if (stage === 'wait-split' && args.get('allow-test-placeholders') !== 'true') throw Error('此源表缺少必填字段,模拟测量须显式允许测试占位') + if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split') + if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') { + throw Error('此源表缺少提交必填字段,须显式允许测试占位') + } const limit = Number(args.get('limit') || 1) if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数') + const concurrency = Number(args.get('concurrency') || 1) + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数') const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true'])) if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名') const input = resolve(args.get('input')) @@ -229,16 +236,22 @@ async function main() { try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录') state ||= { identity, orders: {}, startedAt: new Date().toISOString() } - const lock = await open(resolve(directory, 'run.lock'), 'wx') - const save = async () => { - await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8') + const lockPath = resolve(directory, 'run.lock') + const lock = await acquireRunLock(lockPath) + let saveQueue = Promise.resolve() + const save = () => { + saveQueue = saveQueue.then(async () => { + const temporaryPath = `${statePath}.${process.pid}.tmp` + await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8') for (let attempt = 0; ; attempt++) { - try { await rename(`${statePath}.tmp`, statePath); break } + try { await rename(temporaryPath, statePath); break } catch (error) { if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) } } + }) + return saveQueue } const ctx = new AcceptanceRun(options) const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers)) @@ -264,14 +277,35 @@ async function main() { const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant) const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] } const pallets = [] - for (const code of PALLETS) { - const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) - const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) - if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) - pallets.push(found[0]) + if (stage === 'wait-split') { + for (const code of PALLETS) { + const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) + const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) + if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) + pallets.push(found[0]) + } } const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant) if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO') + let sharedResources = null + if (['pending-review', 'wait-split'].includes(stage)) { + let dimensionRules = null + if (stage === 'wait-split') { + const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) + totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules) + dimensionRules = dimensionConfig.rules + } + let productImageFileId = state.sharedResources?.productImageFileId + if (!productImageFileId) { + const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', + { content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant)) + productImageFileId = image?.fileId + if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING') + } + state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) } + sharedResources = state.sharedResources + await save() + } const candidates = [], rejected = [...normalized.excluded] for (const order of normalized.orders) { try { candidates.push({ order, route: matchRoute(order, catalog) }) } @@ -284,47 +318,88 @@ async function main() { console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length })) await save() if (stage === 'preflight') return - await executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) + await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) } finally { + await saveQueue.catch(() => {}) await lock.close() - const { unlink } = await import('node:fs/promises') - await unlink(resolve(directory, 'run.lock')) + await unlink(lockPath) } } -export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) { - let attempted = 0, succeeded = 0, failed = 0, skipped = 0 +async function acquireRunLock(lockPath) { + try { + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } catch (error) { + if (error.code !== 'EEXIST') throw error + const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => '')) + let alive = false + if (Number.isInteger(existingPid) && existingPid > 0) { + try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ } + } + if (alive) throw Error(`批次正在运行,pid=${existingPid}`) + await unlink(lockPath) + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } +} + +export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) { + let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null + const pending = [] for (const { order, route } of candidates) { // An interrupted write is not permission to create another order. Keep its last // checkpoint for inspection. A reset database is always a NEW batch/directory. - if (state.orders[order.key]) { skipped++; continue } - if (attempted >= limit) break + const previous = state.orders[order.key] + const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED' + && previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code) + if (previous && !retryablePricingWarning) { skipped++; continue } + if (pending.length >= limit) break + pending.push({ order, route, previous }) + } + for (let offset = 0; offset < pending.length; offset += concurrency) { + const batch = pending.slice(offset, offset + concurrency) + await Promise.all(batch.map(async ({ order, route, previous }) => { const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}` - const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString() } + const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(), + ...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage, + error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) } state.orders[order.key] = entry await save() attempted++ try { - const estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { - originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, - destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, - destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, - pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, - actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, - }, merchant) - if (estimate?.channelCode !== route.channel.channelCode || estimate?.pricingTaxMode !== order.pricingTaxMode) throw Error('ESTIMATE_ROUTE_MISMATCH') + let estimate = null + try { + estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { + originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, + destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, + pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, + actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, + }, merchant) + } catch (error) { + if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error + entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message, + requestId: error.evidence.requestId, traceId: error.evidence.traceId } + } + if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) { + throw Error('ESTIMATE_ROUTE_MISMATCH') + } let payload if (stage === 'create') { payload = buildSourceDraft(order, route, profile.tenantName, marker) } else { - const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) - entry.dimensionPlan = totalPreservingShapes(order, config.rules) - entry.stage = 'product-image'; await save() - const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', - { content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant)) - if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING') - entry.productImageFileId = image.fileId - payload = buildPayload(order, route, profile.tenantName, image.fileId, marker) + if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING') + if (stage === 'wait-split') { + if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING') + entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules) + } + entry.productImageFileId = sharedResources.productImageFileId + payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker) } entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save() entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant, @@ -340,9 +415,25 @@ export async function executeCandidates({ candidates, limit, state, save, api, c || Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH') entry.orderNo = draft.order.orderNo entry.declarationVerified = true - if (stage === 'wait-split') { + if (['pending-review', 'wait-split'].includes(stage)) { entry.stage = 'submit'; await save() await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + } + if (stage === 'pending-review') { + const submitted = await api('GET', clientPath, undefined, merchant) + const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant) + if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') { + const error = Error('PENDING_REVIEW_NOT_CONFIRMED') + error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus } + throw error + } + entry.stage = 'PENDING_REVIEW' + entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus, + expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount), + expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg), + expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) } + } + if (stage === 'wait-split') { entry.stage = 'approve'; await save() const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}` const q = `tenantId=${profile.id}` @@ -358,14 +449,22 @@ export async function executeCandidates({ candidates, limit, state, save, api, c entry.status = 'SUCCEEDED'; succeeded++ } catch (error) { entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++ + if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED' } + completed++ entry.finishedAt = new Date().toISOString(); await save() - console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows, - stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence })) - // A small pilot must expose a common deployment/data defect before multiplying it. + if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) { + console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows, + stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, + error: entry.error, evidence: entry.evidence })) + } + })) + if (stopReason) break + // A small concurrent pilot must expose a common deployment/data defect before multiplying it. if (failed >= 3 && succeeded === 0) break } - state.lastRun = { attempted, succeeded, failed, skipped, stage, finishedAt: new Date().toISOString() } + state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason, + finishedAt: new Date().toISOString() } await save() if (failed) process.exitCode = 1 } @@ -383,6 +482,34 @@ async function poll(read, predicate) { throw error } +export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const measured = await api('GET', measurementPath) + if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') + const payload = { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), + items: entry.dimensionPlan.map((group) => { + const item = measured.items.find((value) => String(value.id) === group.itemId) + if (!item) throw Error('MEASUREMENT_GROUP_MISSING') + return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, + coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` } + }), + } + try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) } + catch (error) { + if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error + await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId }) + await wait(250 * attempt) + } + } + throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED') +} + async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) { const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` @@ -411,21 +538,8 @@ async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) group.itemId = String(scan.itemId) cartonIndex += group.count } - const measured = await api('GET', measurementPath) - if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') - const draft = await api('PUT', `${base}/measurement/draft?${q}`, { - measurementId: measured.measurementId, version: Number(measured.version), - actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), - items: entry.dimensionPlan.map((group) => { - const item = measured.items.find((value) => String(value.id) === group.itemId) - if (!item) throw Error('MEASUREMENT_GROUP_MISSING') - return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, - packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, - intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, - outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, - coefficientMode: 'ADD', - remark: `${marker} TEST按总体积分组反算箱规,非实测` } }), - }) + const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } }) const draftCheck = await api('GET', measurementPath) if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001 || Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH') diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs index e38c0130c..1bf1fdfb5 100644 --- a/scripts/real-order-xlsx-test-env.test.mjs +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload, - requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs' + requireSuccess, executeCandidates, totalPreservingShapes, saveMeasurementDraft } from './real-order-xlsx-test-env.mjs' const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] @@ -39,6 +39,15 @@ test('split rows aggregate actual counts once and reconcile forecast', () => { b[4] = 15 assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH')) }) +test('split weight and volume totals use the persisted order precision', () => { + const a = [...row], b = [...row] + a[4] = 90; b[4] = 300; a[7] = 390; b[7] = 390 + a[5] = 1137.461538; b[5] = 3791.538 + a[6] = 7.256538462; b[6] = 24.188 + const result = normalizeRows([header, a, b]).orders[0] + assert.equal(result.weightKg, 4929) + assert.equal(result.volumeCbm, 31.4445) +}) test('conflicting channels on same inbound code block import', () => { const b = [...row]; b[2] = '其他包税' assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道')) @@ -105,9 +114,106 @@ test('HTTP business errors preserve diagnostic evidence', () => { assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }), (error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req') }) +test('measurement draft retries a transient server error after refreshing the version', async () => { + let reads = 0, writes = 0 + const retries = [] + const entry = { dimensionPlan: [{ itemId: 'item-1', count: 31, length: 40, width: 30, height: 30 }] } + const api = async (method) => { + if (method === 'GET') return { measurementId: 'measurement-1', version: ++reads, + items: [{ id: 'item-1', inboundDetailId: 'detail-1', itemNo: 1, boxSpecification: 'TEST' }] } + writes++ + if (writes === 1) { const error = Error('temporary'); error.evidence = { code: 'S0500', requestId: 'req-1' }; throw error } + return { measurementId: 'measurement-1', version: 3 } + } + const result = await saveMeasurementDraft({ api, measurementPath: '/measurement', base: '/inbound/1', q: 'tenantId=1', + order: order(), entry, marker: 'MARKER', wait: async () => {}, onRetry: async (retry) => retries.push(retry) }) + assert.equal(result.version, 3) + assert.equal(reads, 2) + assert.equal(writes, 2) + assert.deepEqual(retries.map((retry) => retry.code), ['S0500']) +}) test('same batch never recreates an attempted order, even when interrupted', async () => { const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } } await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {}, api: async () => { throw Error('must not call HTTP') } }) assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1) }) +test('pending-review stage submits but never calls approval or measurement APIs', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pending-review' }, orders: {} } + const calls = [] + let detailReads = 0 + const api = async (method, path) => { + calls.push(`${method} ${path}`) + if (path.endsWith('/pricing/estimate')) return { channelCode: route.channel.channelCode, pricingTaxMode: value.pricingTaxMode } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-1' + if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { + detailReads++ + return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + assert.equal(Object.values(state.orders)[0].stage, 'PENDING_REVIEW') + assert.equal(Object.values(state.orders)[0].verification.mainStatus, 'PENDING_REVIEW') + assert.equal(calls.some((call) => /approve|measurement|inbounds/.test(call)), false) +}) +test('pending-review retries an estimate-only pricing failure and records it as a warning', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pricing-warning' }, orders: { [value.key]: { status: 'FAILED', stage: 'estimate', + error: 'HTTP_200_CODE_A0404', evidence: { code: 'A0404', requestId: 'old-request' }, finishedAt: '2026-01-01T00:00:00Z' } } } + let detailReads = 0 + const api = async (method, path) => { + if (path.endsWith('/pricing/estimate')) { const error = Error('no price'); error.evidence = { code: 'A0404', message: 'no price' }; throw error } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-2' + if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { + detailReads++ + return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + const entry = state.orders[value.key] + assert.equal(entry.status, 'SUCCEEDED') + assert.equal(entry.pricingWarning.code, 'A0404') + assert.equal(entry.priorAttempts.length, 1) +}) +test('single process respects bounded concurrency while keeping per-order checkpoints', async () => { + const base = order() + const route = matchRoute(base, catalog) + const candidates = Array.from({ length: 7 }, (_, index) => ({ + order: { ...base, key: `key-${index}`, sourceCode: `SRC-${index}`, rows: [index + 2] }, route, + })) + const state = { identity: { batchId: 'concurrency' }, orders: {} } + let active = 0, maximum = 0, id = 0 + const api = async (method, path, body) => { + if (path.endsWith('/pricing/estimate')) { + active++; maximum = Math.max(maximum, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active-- + return { channelCode: route.channel.channelCode, pricingTaxMode: base.pricingTaxMode } + } + if (method === 'POST') return String(++id) + const marker = path.split('/').at(-1) + return { order: { id: marker, orderNo: `ORDER-${marker}`, customerRemark: Object.values(state.orders) + .find((entry) => entry.orderId === marker)?.marker }, cargoes: [{ packageCount: 31, grossWeightKg: 100, volumeCbm: 2 }] } + } + await executeCandidates({ candidates, limit: 7, concurrency: 3, state, save: async () => {}, api, + profile: { tenantName: 'Example' }, stage: 'create' }) + assert.equal(maximum, 3) + assert.equal(state.lastRun.attempted, 7) + assert.equal(state.lastRun.succeeded, 7) + assert.equal(Object.values(state.orders).filter((entry) => entry.status === 'SUCCEEDED').length, 7) +}) From a05b263ca875706359f44e4127cde1c59765f005 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 21:30:39 +0800 Subject: [PATCH 37/42] fix: support multi-customer overseas receiving containers --- common-event-contract/AGENTS.md | 4 +- ...rstLegContainerShippingNodeRecordedV1.java | 4 +- ...egContainerShippingNodeRecordedV1Test.java | 8 +- firstleg/AGENTS.md | 2 +- .../CarrierScheduleNodeOutboxService.java | 6 +- .../CarrierScheduleNodeOutboxServiceTest.java | 3 + lastleg-service/AGENTS.md | 4 +- .../entity/ReceivingContainerOperation.java | 1 - .../pda/entity/ReceivingContainerSession.java | 1 - .../service/InboundPdaWorkflowService.java | 15 +- .../InboundUnloadCompletedOutboxService.java | 21 ++- .../service/ReceivingContainerService.java | 154 ++++++++---------- .../service/LoadingCompletedConsumer.java | 2 +- .../entity/ContainerShippingGate.java | 1 - .../mapper/ContainerShippingGateMapper.java | 17 +- .../ContainerShippingNodeConsumer.java | 10 +- .../src/main/resources/db/lastleg.sql | 10 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 6 + .../InboundPdaContainerCompletionTest.java | 15 +- .../InboundPdaWorkflowServiceTest.java | 2 +- ...boundUnloadCompletedOutboxServiceTest.java | 12 +- ...ReceivingContainerServiceBehaviorTest.java | 64 ++++++-- .../service/LoadingCompletedConsumerTest.java | 6 +- .../ContainerShippingNodeConsumerTest.java | 6 +- 25 files changed, 199 insertions(+), 181 deletions(-) diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 662b2cf74..d3d7e8542 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -16,9 +16,9 @@ | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | -| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | -| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index d3921bd60..9a3734ce8 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -7,7 +7,6 @@ import java.util.Map; /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ public record FirstLegContainerShippingNodeRecordedV1( String queryId, - String customerId, String oceanBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId, String containerNo, @@ -29,8 +28,7 @@ public record FirstLegContainerShippingNodeRecordedV1( "EMPTY_RETURNED", 100); public FirstLegContainerShippingNodeRecordedV1 { - if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() - || oceanBookingId == null || oceanBookingId.isBlank() + if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank() || containerBookingId == null || containerBookingId.isBlank() || containerNo == null || containerNo.isBlank()) { throw new IllegalArgumentException("船节点交接业务标识不能为空"); diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index 807c0829f..ceed06186 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -29,7 +29,7 @@ class FirstLegContainerShippingNodeRecordedV1Test { @Test void rejectsNonCanonicalPhysicalContainerNumber() { assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80, + "1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64))) .hasMessageContaining("normalized"); @@ -38,13 +38,13 @@ class FirstLegContainerShippingNodeRecordedV1Test { @Test void rejectsEstimatedOnlyOrIncompleteProviderFacts() { assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + "1", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:00:00Z"), null, null, null, "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, + "1", "3", "4", "ZCSU7881970", "ARRIVED", 60, Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64))) .isInstanceOf(IllegalArgumentException.class); @@ -52,7 +52,7 @@ class FirstLegContainerShippingNodeRecordedV1Test { private static FirstLegContainerShippingNodeRecordedV1 payload() { return new FirstLegContainerShippingNodeRecordedV1( - "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000001", "2088000000000000004", "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 872160af5..7d925c740 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -47,7 +47,7 @@ | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `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/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` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `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 完成不推进拖车状态 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 55fab5082..725e45ba4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -44,7 +44,7 @@ public class CarrierScheduleNodeOutboxService { } ZoneId zone = ZoneId.of(scheduleZone); var payload = new FirstLegContainerShippingNodeRecordedV1( - string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getId()), string(query.getOceanBookingId()), string(query.getContainerBookingId()), normalizedContainerNo, fact.getStandardNode(), fact.getNodeSequence(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), @@ -57,12 +57,12 @@ public class CarrierScheduleNodeOutboxService { EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, - string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + "0", "CONTAINER_BOOKING", string(query.getContainerBookingId()), aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, payloadNode, payloadHash).requireValid(); InboundOutboxEntity row = new InboundOutboxEntity(); - row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setId(IdWorker.getId()); row.setTenantId(0L); row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index ee3d3041a..0a0c66761 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -50,6 +50,8 @@ class CarrierScheduleNodeOutboxServiceTest { assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); assertThat(envelope.eventVersion()).isEqualTo(1); assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.tenantId()).isEqualTo("0"); + assertThat(row.getTenantId()).isZero(); assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); assertThat(envelope.aggregateVersion()).isEqualTo(7); assertThat(envelope.handoverVersion()).isEqualTo(8); @@ -57,6 +59,7 @@ class CarrierScheduleNodeOutboxServiceTest { assertThat(envelope.payload().path("sourceContainerBookingId").asText()) .isEqualTo("2088000000000000003"); assertThat(envelope.payload().has("containerBookingId")).isFalse(); + assertThat(envelope.payload().has("customerId")).isFalse(); assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index f255e4cd7..dbe8ac3c4 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -35,7 +35,7 @@ | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | -| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | | 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | @@ -95,7 +95,7 @@ - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 - 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 -- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java index 18d2bfcac..e33c0ac3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -13,7 +13,6 @@ public class ReceivingContainerOperation { @TableId(type = IdType.INPUT) private Long id; private Long sessionId; - private String customerId; private String operatorKey; private String action; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 7b8ade622..17508259b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -15,7 +15,6 @@ import java.time.LocalDateTime; public class ReceivingContainerSession { @TableId(type = IdType.INPUT) private Long id; - private String customerId; private String sourceContainerBookingId; private String containerFactHash; private String containerNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 578da55e5..03f8b1ac9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -546,33 +546,34 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Task complete(String customerId, String planNo) { InboundPlan plan = requirePlan(customerId, planNo); - return completeContainer(customerId, List.of(plan)).get(0); + return completeContainer(List.of(plan)).get(0); } /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ @Transactional - public List completeContainer(String customerId, List containerPlans) { + public List completeContainer(List containerPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); String containerNo = orderedPlans.get(0).getContainerNo(); if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> - !customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { - throw conflict("Inbound plans must belong to one customer and one physical container"); + plan.getCustomerId() == null || plan.getCustomerId().isBlank() || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must have an owning customer and belong to one physical container"); } - List contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); + List contexts = orderedPlans.stream() + .map(plan -> completionContext(plan.getCustomerId(), plan)).toList(); boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus())); if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required"); LocalDateTime now = LocalDateTime.now(); - for (CompletionContext context : contexts) completePlan(customerId, context, now); + for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now); String sourceContainerBookingId = sourceContainerBookingId(contexts); LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) .max(LocalDateTime::compareTo).orElseThrow(); long aggregateVersion = contexts.stream().map(CompletionContext::task) .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); - unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo, now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 535f62079..60de90bae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -47,7 +47,7 @@ public class InboundUnloadCompletedOutboxService { if (task.getUnloadPhotoUploadedAt() == null) { throw new IllegalStateException("Inbound task has no unload photo upload time"); } - publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(), + publishContainerCompleted(sourceOrder.getSourceContainerBookingId(), plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC), task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); @@ -56,10 +56,9 @@ public class InboundUnloadCompletedOutboxService { /** * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 */ - public void publishContainerCompleted(String customerId, String sourceContainerBookingId, - String containerNo, Instant unloadingCompletedAt, - Instant unloadingPhotoUploadedAt, long aggregateVersion) { - requireText(customerId, "customerId"); + public void publishContainerCompleted(String sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { requireText(sourceContainerBookingId, "sourceContainerBookingId"); requireText(containerNo, "containerNo"); Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); @@ -71,7 +70,7 @@ public class InboundUnloadCompletedOutboxService { String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; LastlegEventOutbox existing = mapper.findByEventKey(eventKey); if (existing != null) { - requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); + requireSameContainer(existing, sourceContainerBookingId, containerNo); return; } var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, @@ -79,12 +78,12 @@ public class InboundUnloadCompletedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING", + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING", sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); - event.setId(IdWorker.getId()); event.setCustomerId(customerId); + event.setId(IdWorker.getId()); event.setCustomerId("0"); event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); @@ -94,17 +93,17 @@ public class InboundUnloadCompletedOutboxService { } catch (DuplicateKeyException exception) { LastlegEventOutbox raced = mapper.findByEventKey(eventKey); if (raced == null) throw exception; - requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo); + requireSameContainer(raced, sourceContainerBookingId, containerNo); } } - private void requireSameContainer(LastlegEventOutbox existing, String customerId, + private void requireSameContainer(LastlegEventOutbox existing, String sourceContainerBookingId, String containerNo) { try { EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( envelope.payload(), OverseasContainerUnloadedV1.class); - if (!Objects.equals(existing.getCustomerId(), customerId) + if (!"0".equals(existing.getCustomerId()) || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) || !Objects.equals(frozen.containerNo(), containerNo)) { throw new IllegalStateException("Unload-completed event key conflicts with another container"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 31ed2e281..4f6aac638 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -59,6 +59,7 @@ import java.util.stream.Collectors; @Service public class ReceivingContainerService { + private static final String PLATFORM_SCOPE = "0"; private static final String PHOTO_DOWNLOAD_PATH = "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; private static final String PALLET_MERGE_HINT = @@ -125,10 +126,9 @@ public class ReceivingContainerService { } public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { + requireReadyContainer(containerNo, overseasWarehouseId, false); List containerPlans = requirePlans(containerNo); - String customerId = requireSingleCustomer(containerPlans, containerNo); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); - requireReceivingAvailable(customerId, containerNo); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { @@ -141,7 +141,7 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); + operatorKey, receivingWarehouse, containerNo), containerPlans); } @Transactional @@ -158,9 +158,7 @@ public class ReceivingContainerService { return result; } List containerPlans = requirePlans(containerNo); - String customerId = requireSingleCustomer(containerPlans, containerNo); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); - requireReceivingAvailable(customerId, containerNo); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); @@ -179,7 +177,7 @@ public class ReceivingContainerService { } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); - session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setId(IdWorker.getId()); session.setContainerNo(containerNo); session.setSourceContainerBookingId(readiness.sourceContainerBookingId()); session.setContainerFactHash(readiness.factHash()); session.setOverseasWarehouseId(overseasWarehouseId); @@ -197,9 +195,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { ReceivingContainerSession session = requireCurrent(operatorKey, true); - String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); - UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(PLATFORM_SCOPE, session.getId(), operatorId, file); return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @@ -209,24 +206,24 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireCurrent(operatorKey, true); - String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); - photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); - for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { - workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); + List containerPlans = requirePlans(session.getContainerNo()); + for (InboundPlan plan : containerPlans) { + workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now); } - ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); save(session, "SIGN", request.idempotencyKey(), request, result); return result; } public ReceivingContainerResponses.Session current(String operatorKey) { ReceivingContainerSession session = requireCurrent(operatorKey, false); - return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); + return snapshot(session, requirePlans(session.getContainerNo())); } public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { @@ -236,7 +233,7 @@ public class ReceivingContainerService { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Unload photo is not part of the current receiving session"); } - return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId); + return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId); } public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { @@ -246,9 +243,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); String inboundCode = cartonInboundCode(request.cartonMark()); - List matches = refs(customerId, session.getContainerNo()).stream() + List matches = refs(session.getContainerNo()).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); CartonRef carton; if (matches.size() > 1) { @@ -256,9 +252,9 @@ public class ReceivingContainerService { } else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) { carton = new CartonRef(matches.get(0)); } else if (matches.size() == 1) { - carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); } else { - carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode); + carton = requireUnmatchedFbaCartonPrefix(inboundCode); } InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); @@ -273,8 +269,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), @@ -287,12 +282,12 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + String customerId = ref.order().getCustomerId(); if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - List fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); + List fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref); List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), allocation.packages())); @@ -328,12 +323,14 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List currentRefs = refs(customerId, session.getContainerNo()); + List currentRefs = refs(session.getContainerNo()); if (currentRefs.stream().noneMatch(value -> "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } + String inboundCode = cartonInboundCode(request.cartonMark()); + InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); + String customerId = inboundRef.order().getCustomerId(); CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { @@ -367,12 +364,11 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List containerPlans = requirePlans(customerId, session.getContainerNo()); + List containerPlans = requirePlans(session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); - validateFbaSkuBalance(customerId, session.getContainerNo()); - workflow.completeContainer(customerId, containerPlans); + validateFbaSkuBalance(session.getContainerNo()); + workflow.completeContainer(containerPlans); LocalDateTime now = LocalDateTime.now(); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); @@ -446,7 +442,6 @@ public class ReceivingContainerService { Set warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName) .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1; - Set customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet()); String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null); int totalOrders = orderFacts.size(); int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim) @@ -456,14 +451,13 @@ public class ReceivingContainerService { return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId()); }).count(); int planCount = matchedPlans.size(); - boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders + boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; List containerPlans = planFacts.stream() .filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); boolean allPlansCompleted = !containerPlans.isEmpty() && containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus())); - String customerId = customers.size() == 1 ? customers.iterator().next() : null; - boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1; + boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent @@ -521,42 +515,28 @@ public class ReceivingContainerService { List values = plans.selectList(new LambdaQueryWrapper() .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); if (values.isEmpty()) throw notFound("Receiving container was not found"); - requireSingleCustomer(values, containerNo); + if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Every inbound plan in the receiving container must have an owning customer"); + } if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { throw conflict("Receiving container is already completed"); } return values; } - private List requirePlans(String customerId, String containerNo) { - List values = plansForContainer(customerId, containerNo); - if (values.isEmpty()) throw notFound("Receiving container was not found"); - if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { - throw conflict("Receiving container is already completed"); - } - return values; - } - - private List plansForContainer(String customerId, String containerNo) { + private List plansForContainer(String containerNo) { List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .eq(InboundPlan::getContainerNo, containerNo) .orderByAsc(InboundPlan::getId)); if (values.isEmpty()) throw notFound("Receiving container was not found"); return values; } - private String requireSingleCustomer(List values, String containerNo) { - Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) - .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); - if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { - throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); - } - return customers.iterator().next(); - } - - private List refs(String customerId, String containerNo) { + private List refs(String containerNo) { List result = new ArrayList<>(); - for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlan plan : plansForContainer(containerNo)) { + String customerId = trim(plan.getCustomerId()); + if (customerId == null) throw conflict("Inbound plan has no owning customer"); for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) .orderByAsc(InboundPlanLine::getId))) { @@ -568,8 +548,8 @@ public class ReceivingContainerService { return result; } - private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { - List matches = refs(customerId, containerNo).stream() + private InboundRef requireInbound(String containerNo, String inboundCode) { + List matches = refs(containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); @@ -581,7 +561,8 @@ public class ReceivingContainerService { InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); if (carton == null) throw notFound("Carton mark was not found"); - List matches = refs(customerId, containerNo).stream() + List matches = refs(containerNo).stream() + .filter(value -> customerId.equals(value.order().getCustomerId())) .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); @@ -597,16 +578,15 @@ public class ReceivingContainerService { return inboundCode; } - private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) { + private CartonRef requireUnmatchedFbaCartonPrefix(String inboundCode) { boolean exists = lines.selectList(new LambdaQueryWrapper() - .eq(InboundPlanLine::getCustomerId, customerId) .eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent(); if (exists) throw conflict("Carton mark belongs to another container"); throw notFound("Carton mark was not found"); } - private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { - List values = refs(customerId, containerNo).stream() + private List requireFbaPlanRefs(String containerNo, InboundRef selected) { + List values = refs(containerNo).stream() .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) .sorted(Comparator.comparing(value -> value.line().getId())).toList(); if (values.isEmpty() || values.stream().anyMatch(value -> @@ -635,11 +615,12 @@ public class ReceivingContainerService { } private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { + String normalizedPalletCode = palletCode.trim(); List reserved = pallets.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() .filter(value -> sameDestination(ref.line(), value)) - .filter(value -> palletCode.trim().equals(value.getPalletNo())) + .filter(value -> matchesPlannedPalletCode(value, normalizedPalletCode)) .filter(this::hasConfirmedReservation) .toList(); if (reserved.size() > 1) { @@ -651,11 +632,11 @@ public class ReceivingContainerService { pallet = reserved.get(0); long warehouseId = ref.plan().getOverseasWarehouseId() == null ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); - resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId, + resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); } else { try { - resource = warehouse.resolveAvailablePallet(palletCode.trim()); + resource = warehouse.resolveAvailablePallet(normalizedPalletCode); } catch (IllegalArgumentException exception) { throw notFound("Pallet was not found"); } @@ -666,6 +647,11 @@ public class ReceivingContainerService { return new PalletSelection(pallet, resource); } + private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) { + return scannedCode.equals(trim(pallet.getPalletNo())) + || scannedCode.equals(trim(pallet.getSourcePalletNo())); + } + private boolean hasConfirmedReservation(InboundPlanPallet pallet) { return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 @@ -706,7 +692,7 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehousePalletReference resource) { } private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { - List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() + List inbounds = refs(session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); @@ -749,8 +735,8 @@ public class ReceivingContainerService { } private Totals totals(List containerPlans) { - List values = refs(containerPlans.get(0).getCustomerId(), - containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + List values = refs(containerPlans.get(0).getContainerNo()) + .stream().map(this::inbound).toList(); List destinationCodes = values.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); return new Totals(values.size(), destinationCodes, @@ -856,14 +842,15 @@ public class ReceivingContainerService { } private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { - List existing = skuReceipts.lockLineSku(session.getCustomerId(), + String customerId = sku.ref().order().getCustomerId(); + List existing = skuReceipts.lockLineSku(customerId, sku.ref().line().getId(), sku.skuCode()); ReceivingSkuReceipt receipt = existing.stream() .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); LocalDateTime now = LocalDateTime.now(); if (receipt == null) { receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); - receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setCustomerId(customerId); receipt.setSessionId(session.getId()); receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); @@ -877,9 +864,10 @@ public class ReceivingContainerService { } } - private void validateFbaSkuBalance(String customerId, String containerNo) { - for (InboundRef ref : refs(customerId, containerNo)) { + private void validateFbaSkuBalance(String containerNo) { + for (InboundRef ref : refs(containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + String customerId = ref.order().getCustomerId(); List skuCodes = skuExpectations(ref); int received = 0; for (SkuExpectation sku : skuCodes) { @@ -914,10 +902,10 @@ public class ReceivingContainerService { if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); } - private ReceivingContainerSession waitingView(String customerId, String operatorKey, + private ReceivingContainerSession waitingView(String operatorKey, WarehouseResourceGateway.WarehouseReference warehouse, String containerNo) { - ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + ReceivingContainerSession value = new ReceivingContainerSession(); value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseName(warehouse.warehouseName()); @@ -925,12 +913,6 @@ public class ReceivingContainerService { return value; } - private boolean eligible(List containerPlans, long overseasWarehouseId) { - String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo()); - return warehouseEligible(containerPlans, overseasWarehouseId) - && shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1; - } - private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) .filter(Objects::nonNull).collect(Collectors.toSet()); @@ -956,12 +938,6 @@ public class ReceivingContainerService { } } - private void requireReceivingAvailable(String customerId, String containerNo) { - if (shippingGates.receivingAvailable(customerId, containerNo) != 1) { - throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving"); - } - } - private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { throw conflict("Current container belongs to another overseas warehouse"); @@ -981,7 +957,7 @@ public class ReceivingContainerService { private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { key = idempotencyKey(key); ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); - value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setSessionId(session.getId()); value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); value.setCreatedAt(LocalDateTime.now()); operations.insert(value); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 6ff108938..a227164d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -116,7 +116,7 @@ public class LoadingCompletedConsumer { private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId, String containerNo) { - shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "", + shippingGates.initialize(sourceContainerBookingId, "", containerNo, 0, envelope.handoverVersion()); ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId); if (frozen == null) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java index 26b248db4..23bb9111e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -12,7 +12,6 @@ import java.time.LocalDateTime; public class ContainerShippingGate { @TableId(type = IdType.INPUT) private String containerBookingId; - private String customerId; private String oceanBookingId; private String containerNo; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java index c505dca92..502508040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -12,18 +12,17 @@ public interface ContainerShippingGateMapper extends BaseMapper captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); @@ -57,6 +57,8 @@ class InboundUnloadCompletedOutboxServiceTest { assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt); assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt); assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"); + assertThat(row.getCustomerId()).isEqualTo("0"); + assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.aggregateId()).isEqualTo("booking-1"); } @@ -87,11 +89,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test void repeatedContainerCompletionDoesNotPublishAnotherEvent() { - LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870"); + LastlegEventOutbox existing = existing("0", "booking-1", "MSCU6639870"); when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) .thenReturn(existing); - service.publishContainerCompleted("100", "booking-1", "MSCU6639870", + service.publishContainerCompleted("booking-1", "MSCU6639870", Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8); verify(mapper, never()).insert(any(LastlegEventOutbox.class)); @@ -99,11 +101,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() { - LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870"); + LastlegEventOutbox existing = existing("0", "booking-1", "MSCU6639870"); when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) .thenReturn(existing); - assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "ZCSU7881970", + assertThatThrownBy(() -> service.publishContainerCompleted("booking-1", "ZCSU7881970", Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("conflicts with another container"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 197463131..e170f209f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -93,7 +93,7 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); when(orders.selectList(any())).thenReturn(List.of(order)); - when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0); + when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); assertThat(service.candidates("7", 100L)).isEmpty(); assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", @@ -118,7 +118,6 @@ class ReceivingContainerServiceBehaviorTest { assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); ArgumentCaptor selected = ArgumentCaptor.forClass(ReceivingContainerSession.class); verify(sessions).insert(selected.capture()); - assertThat(selected.getValue().getCustomerId()).isEqualTo("100"); assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7"); assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A"); ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); @@ -131,7 +130,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() { + void pickerCandidatesAllowMultipleCustomersInTheSameContainer() { InboundPlan first = plan("CONT-A"); InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second)); @@ -149,7 +148,7 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2"))); - assertThat(service.candidates("7", 100L)).isEmpty(); + assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A"); } @Test @@ -311,6 +310,9 @@ class ReceivingContainerServiceBehaviorTest { @Test void detailRejectsContainerReservedByAnotherOperator() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); ReceivingContainerSession other = session("CONT-B", "SIGNED"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setActiveContainerKey("CONTAINER:CONT-B"); @@ -324,6 +326,9 @@ class ReceivingContainerServiceBehaviorTest { @Test void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); @@ -397,8 +402,6 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); stubCurrent("OVERSEAS", "CONT-A"); - when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1")); - when(pallets.selectList(any())).thenReturn(List.of(planPallet())); assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) .isInstanceOf(ResponseStatusException.class); @@ -429,7 +432,7 @@ class ReceivingContainerServiceBehaviorTest { when(allocations.selectList(any())).thenReturn(List.of(partial)); assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); - verify(workflow, never()).completeContainer(any(), any()); + verify(workflow, never()).completeContainer(any()); } @Test @@ -492,6 +495,39 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.ArgumentMatchers.same(occupied)); } + @Test + void bulkReceivingAcceptsFrozenSourcePalletCodeForItsPreReservedResource() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + InboundPlanPallet reserved = planPallet(); + reserved.setPalletNo("PAL-RESERVED"); + reserved.setSourcePalletNo("CC001"); + reserved.setWarehousePalletId(30L); + reserved.setWarehouseLocationId(40L); + reserved.setWarehouseResourceLockId(50L); + reserved.setAssignmentStatus("RESERVED"); + when(pallets.selectList(any())).thenReturn(List.of(reserved)); + WarehouseResourceGateway.WarehousePalletReference occupied = + new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L, + "OCCUPIED", "FBA-1", 0); + when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L)) + .thenReturn(occupied); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "CC001", 1, "source-code-key")); + + assertThat(result.palletCode()).isEqualTo("PAL-RESERVED"); + verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L); + verify(warehouse, never()).resolveAvailablePallet("CC001"); + verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"), + any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved), + org.mockito.ArgumentMatchers.same(occupied)); + } + @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); @@ -516,9 +552,9 @@ class ReceivingContainerServiceBehaviorTest { "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2092476334483931138/download", "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2/download", "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download"); - verify(unloadPhotos).validate("100", 8L, 2092476334483931138L); - verify(unloadPhotos).validate("100", 8L, 2L); - verify(unloadPhotos).validate("100", 8L, 3L); + verify(unloadPhotos).validate("0", 8L, 2092476334483931138L); + verify(unloadPhotos).validate("0", 8L, 2L); + verify(unloadPhotos).validate("0", 8L, 3L); } @Test @@ -558,7 +594,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void currentSignedPhotoCanBeDownloadedOnlyThroughItsOperatorCustomerAndSessionScope() { + void currentSignedPhotoCanBeDownloadedOnlyThroughItsPlatformSessionScope() { ReceivingContainerSession current = session("CONT-A", "SIGNED"); current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"}," + "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"}," @@ -566,10 +602,10 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current); UnloadPhotoFileGateway.DownloadedFile expected = new UnloadPhotoFileGateway.DownloadedFile("image".getBytes(), "plate.jpg", "image/jpeg"); - when(unloadPhotos.download("100", 8L, 9L)).thenReturn(expected); + when(unloadPhotos.download("0", 8L, 9L)).thenReturn(expected); assertThat(service.downloadCurrentPhoto("7", "9")).isSameAs(expected); - verify(unloadPhotos).download("100", 8L, 9L); + verify(unloadPhotos).download("0", 8L, 9L); } @Test @@ -781,7 +817,7 @@ class ReceivingContainerServiceBehaviorTest { private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); - value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setStatus(status); value.setVersion(0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 54f964f57..021ef7d9f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,17 +4,17 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } - @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } + @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index aff149eb8..2df7d6fb5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -113,7 +113,7 @@ class ContainerShippingNodeConsumerTest { private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); - gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("MSCU6639870"); + gate.setOceanBookingId("OB-1"); gate.setContainerNo("MSCU6639870"); gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version); gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, java.time.ZoneOffset.UTC)); return gate; @@ -121,12 +121,12 @@ class ContainerShippingNodeConsumerTest { private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) { FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1( - "Q-1", "100", "OB-1", "CB-1", "MSCU6639870", node, sequence, actualAt, + "Q-1", "OB-1", "CB-1", "MSCU6639870", node, sequence, actualAt, actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64)); JsonNode payloadNode = json.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1", + actualAt, RegionCode.CN, RegionCode.OVERSEAS, "0", "CONTAINER_BOOKING", "CB-1", version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null, payloadNode, hash); } From 107374bec538a531dc35b98d752bbef5216cf185 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 21:37:01 +0800 Subject: [PATCH 38/42] fix: align overseas container scope migration --- deploy/sql/海外区域统一增量-MySQL8.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 40d184cae..c9a6d0cdc 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1917,6 +1917,20 @@ DROP PROCEDURE assert_receiving_warehouse_assignment_ready; SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message; +-- 海外 PDA 接柜、作业与船节点均按柜级建模;客户归属保留在柜内各入仓计划。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_receiving_container_session' AND column_name='customer_id'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN customer_id', 'SELECT 1')); +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_receiving_container_operation' AND column_name='customer_id'), + 'ALTER TABLE ll_receiving_container_operation DROP COLUMN customer_id', 'SELECT 1')); +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_container_shipping_gate' AND column_name='customer_id'), + 'ALTER TABLE ll_container_shipping_gate DROP COLUMN customer_id', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + -- 一件代发来源托盘与海外仓实物托盘分离,实物绑定不得覆盖来源身份。 USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() From 3e4b9d0a9a3b2a0b85a94450cffe0919b5bf89a6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 23:46:21 +0800 Subject: [PATCH 39/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 | 6 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 47 +- deploy/sql/海外区域统一增量-MySQL8.sql | 2536 ++++++++++++++++- .../main/resources/db/lastleg-permissions.sql | 6 + lastleg-service/AGENTS.md | 2 +- .../src/main/resources/db/lastleg.sql | 125 +- .../security/LastlegSecurityContractTest.java | 18 +- scripts/README.md | 7 +- .../initialization-baseline-contract.test.mjs | 82 + scripts/local-db-full-acceptance.ps1 | 160 +- 10 files changed, 2886 insertions(+), 103 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 501c91676..e8d961513 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,8 +3,8 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 -- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 +- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总订单与客户、入库测量与计费及已实现国内货柜履约出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 @@ -239,7 +239,7 @@ SELECT - 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。 -- 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。 +- 海外仓出库与派送数据库部署只执行 `海外区域统一增量-MySQL8.sql`;无论空库还是存量测试库都不得在此前单独执行 Lastleg 或 Warehouse Overseas 模块基线,完整执行固定入口并确认最末 `migration_status = PASS`。 - 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。 - 固定入口只补目标字段并做 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)` 完成命令唯一约束;旧客户复合索引不保留。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index beb436e62..bf3caab7e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -12831,6 +12831,40 @@ CALL queue_table_comment('baiyue_order', 'ord_order_status_history', '订单状 CALL queue_table_comment('baiyue_order', 'ord_outbox_event', '订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'); CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱'); + +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot ( + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID', + event_id VARCHAR(128) NOT NULL COMMENT 'Order Outbox事件ID', + order_version INT NOT NULL COMMENT 'Order聚合版本,严格单调消费', + request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256', + customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照', + expected_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间快照', + channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', + channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照', + cargoes_json JSON NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实', + changes_json JSON NULL COMMENT '本版本申报字段变化摘要', + occurred_at DATETIME(3) NOT NULL COMMENT 'Order事件发生时间', + 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 (tenant_id, order_id), + UNIQUE KEY uk_order_declaration_event (tenant_id, event_id), + KEY idx_order_declaration_version (tenant_id, order_id, order_version) +) ENGINE=InnoDB COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'; + +CREATE TABLE IF NOT EXISTS fl_order_declaration_event ( + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + event_id VARCHAR(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID', + order_version INT NOT NULL COMMENT '事件携带的Order聚合版本', + request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', + consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件消费完成时间', + PRIMARY KEY (tenant_id, event_id), + KEY idx_order_declaration_event_order (tenant_id, order_id, order_version) +) ENGINE=InnoDB COMMENT='Order客户申报事件永久幂等账本'; + CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_event', 'Order客户申报事件永久幂等账本'); CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_snapshot', 'Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'); CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0'); @@ -16873,6 +16907,12 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107230,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:carton:list','海外箱号','/overseas-warehouse/cartons','GET',930,'ACTIVE',1,0), + (5107231,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:create','创建海外箱号',NULL,'POST',931,'ACTIVE',1,0), + (5107232,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:edit','编辑海外箱号',NULL,'PUT',932,'ACTIVE',1,0), + (5107240,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:product:list','海外产品库','/overseas-warehouse/products','GET',940,'ACTIVE',1,0), + (5107241,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:create','创建海外产品',NULL,'POST',941,'ACTIVE',1,0), + (5107242,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:edit','编辑海外产品',NULL,'PUT',942,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), @@ -18111,7 +18151,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE lx_order_ddl FROM @ddl; EXECUTE lx_order_ddl; DEALLOCATE PREPARE lx_order_ddl; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE baiyue_firstleg.fl_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码'' AFTER fulfillment_type')); PREPARE lx_inbound_ddl FROM @ddl; EXECUTE lx_inbound_ddl; DEALLOCATE PREPARE lx_inbound_ddl; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); +SET @ddl = (SELECT IF( + NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order') + OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'), + 'SELECT 1', + 'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type' +)); PREPARE lx_outbound_ddl FROM @ddl; EXECUTE lx_outbound_ddl; DEALLOCATE PREPARE lx_outbound_ddl; ALTER TABLE baiyue_firstleg.fl_inbound_order diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c9a6d0cdc..573d1ab37 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,8 +1,2459 @@ --- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线; --- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。 +-- 海外区域唯一数据库初始化入口;空库和存量测试库均只执行本文件。 +-- 本文件内联 Lastleg 与海外仓空库基线,再执行统一增量和最终结构校验。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 SET NAMES utf8mb4; +-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY(event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) 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(container_booking_id), KEY idx_ll_shipping_gate_candidate(container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + +CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花 ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) COMMENT '来源订单 ID', + source_order_no VARCHAR(128) COMMENT '来源订单号', + root_order_id VARCHAR(128) COMMENT '根订单 ID', + customer_name VARCHAR(255) COMMENT '货主名称', + source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照', + source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', + overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', + transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', + transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', + container_no VARCHAR(64) COMMENT '集装箱号', + container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是', + hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空', + manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点', + version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + product_quantity INT COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) COMMENT '入库单号', + outbound_order_no VARCHAR(128) COMMENT '出库单号', + delivery_order_no VARCHAR(128) COMMENT '派送单号', + relabel_order_no VARCHAR(128) COMMENT '换标单号', + relabel_status VARCHAR(32) COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) COMMENT '合托标记', + pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成', + sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', + private_remark VARCHAR(1000) COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', + source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', + source_handover_version BIGINT COMMENT '交接版本', + source_payload_hash VARCHAR(71) 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), + UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id), + KEY idx_ll_order_group(customer_id,order_no), + KEY idx_ll_order_container(customer_id,container_no), + KEY idx_ll_order_list(customer_id,current_status,created_at), + KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +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='头程交接的不可变托盘码快照'; + + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); +PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + sku_code VARCHAR(128) COMMENT 'SKU 编码', + product_name_zh VARCHAR(255) COMMENT '中文品名', + product_name_en VARCHAR(255) COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT COMMENT '数量', + pallet_count INT COMMENT '托数', + gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) COMMENT '货件编号', + shipping_mark VARCHAR(255) COMMENT '唛头', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), + KEY idx_ll_detail_order(order_id), + KEY idx_ll_detail_customer_order(customer_id,order_id,id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + from_status VARCHAR(32) COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) COMMENT '触发事件 ID', + remark VARCHAR(500) COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY(id), + KEY idx_ll_loading_history_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 '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单操作记录'; + +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用', + result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; + +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', + chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', + updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( + id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '接柜仓修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key), + KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; + +-- 派送单列表与详情快照、转派送费用及操作记录。 +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '派送单主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', + outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', + waybill_no VARCHAR(128) NULL COMMENT '承运运单号', + bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', + documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', + delivery_method VARCHAR(32) NULL COMMENT '派送方式', + carrier_name VARCHAR(255) NULL COMMENT '派送承运商', + pickup_method VARCHAR(64) NULL COMMENT '提货方式', + loading_type VARCHAR(64) NULL COMMENT '装车类型', + vehicle_type VARCHAR(64) NULL COMMENT '车辆类型', + vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机或车队名称', + driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', + appointment_account VARCHAR(128) NULL COMMENT '预约账号', + seller_name VARCHAR(128) NULL COMMENT '卖家姓名', + package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数', + total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', + dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', + expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', + remark VARCHAR(1000) NULL COMMENT '派送备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 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), + UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'' AFTER overseas_warehouse_id')); +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='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); +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='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); +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_document_revision ( + id BIGINT NOT NULL COMMENT '派送资料历史ID', + customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + version BIGINT NOT NULL COMMENT '保存成功时派送单版本', + idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希', + snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID', + created_at DATETIME(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY(id), + UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key), + UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version) +) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录'; + +CREATE TABLE IF NOT EXISTS ll_delivery_operation ( + id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id) +) ENGINE=InnoDB COMMENT='派送单操作记录'; + +CREATE TABLE IF NOT EXISTS ll_event_conflict ( + id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,customer_id) +) ENGINE=InnoDB COMMENT='事件冲突记录'; + +CREATE TABLE IF NOT EXISTS ll_event_inbox ( + id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(customer_id,aggregate_id) +) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order ( + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '客户账号标识', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + inbound_code VARCHAR(128) NULL COMMENT '入仓码', lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(customer_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id) +) ENGINE=InnoDB COMMENT='尾程入仓单快照'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order_item ( + id BIGINT NOT NULL COMMENT '入仓明细主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头', + sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE IF NOT EXISTS ll_order_status_history ( + id BIGINT NOT NULL COMMENT '订单状态历史主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='尾程订单状态历史'; + +-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 +ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; +ALTER TABLE ll_order COMMENT='尾程订单主表'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1')); +PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); +PREPARE lastleg_order_container_progress_idx_stmt FROM @ddl; EXECUTE lastleg_order_container_progress_idx_stmt; DEALLOCATE PREPARE lastleg_order_container_progress_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓 ID 快照'' AFTER customer_name')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; +ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; +ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; +ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; +ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱'; +ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照'; +ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照'; +ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史'; + +ALTER TABLE ll_event_conflict + MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_event_inbox + MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', + MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order_item + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_order_status_history + MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'; + +ALTER TABLE ll_loading_event_inbox + MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间', + MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误'; + +ALTER TABLE ll_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID', + MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', + MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', + MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', + MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', + MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量', + MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态', + MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', + MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', + MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Existing installations: convert the row identity to an assigned Snowflake ID and +-- make order_no a container/loading-plan grouping number rather than a row-unique key. +SET @ll_add_transfer_warehouse_id = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_id' +); +PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id; +EXECUTE ll_add_transfer_warehouse_id_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt; + +SET @ll_add_transfer_warehouse_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_code' +); +PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code; +EXECUTE ll_add_transfer_warehouse_code_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt; + +SET @ll_add_transfer_warehouse_name = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_name' +); +PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name; +EXECUTE ll_add_transfer_warehouse_name_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt; + +ALTER TABLE ll_order + MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称'; + +SET @ll_drop_order_no_unique = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'uk_ll_order_no' AND non_unique = 0 +); +PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique; +EXECUTE ll_drop_order_no_unique_stmt; +DEALLOCATE PREPARE ll_drop_order_no_unique_stmt; + +SET @ll_add_order_group_index = ( + SELECT IF(COUNT(*) = 0, + 'CREATE INDEX idx_ll_order_group ON ll_order(customer_id,order_no)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'idx_ll_order_group' +); +PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index; +EXECUTE ll_add_order_group_index_stmt; +DEALLOCATE PREPARE ll_add_order_group_index_stmt; + +ALTER TABLE ll_order_detail + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', + MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN product_quantity INT NULL COMMENT '数量', + MODIFY COLUMN pallet_count INT NULL COMMENT '托数', + MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号', + MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID', + planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', + assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', + merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', + urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', + creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', + status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 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 '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '入库计划乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id), + KEY idx_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; + +CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( + id BIGINT NOT NULL COMMENT '柜级转换命令ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', + request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', + snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', + snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', + warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', + preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', + result_json JSON NULL COMMENT '本地计划提交结果', + last_error VARCHAR(1000) 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), + UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), + UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), + KEY idx_ll_container_conversion_status(status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), + KEY idx_ll_inbound_line_plan (plan_id), + KEY idx_ll_inbound_line_customer_plan (customer_id, plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '生成尾程订单时绑定的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '海外仓资源服务返回的真实托盘码', + source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id), + KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_customer_plan (customer_id, plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +ALTER TABLE ll_inbound_plan + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +ALTER TABLE ll_inbound_plan_line + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量'; + +ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT '海外仓资源服务返回的真实托盘码', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号'; + +ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +ALTER TABLE ll_inbound_plan_pallet_allocation + MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; + +-- Additive inbound picking schema. Guarded statements preserve existing rows on restart. +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态:PENDING_ASSIGNMENT待分配及后续正式状态'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划明细应收箱数''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁ID''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''托盘分配状态:UNASSIGNED未分配及后续正式状态''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实收体积(立方米)''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实收箱数''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实收体积(立方米)''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''分配作业状态:PENDING_COUNT待清点及后续正式状态''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''托盘分配乐观锁版本''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', + pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + unload_photo_file_ids JSON NULL COMMENT '卸柜照片文件ID列表', unload_photo_uploaded_at DATETIME(3) NULL COMMENT '卸柜照片上传成功时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', + version INT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; + +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 '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + 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 '人民币结算金额', 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', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + 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')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); +PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), + KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), + KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容JSON', result_json LONGTEXT NOT NULL COMMENT '处理结果JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (customer_id, idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='入仓PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + merge_group_no VARCHAR(64) NOT NULL COMMENT '合托分组号', source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配记录ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配记录ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否已报废', status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_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='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +DROP TEMPORARY TABLE IF EXISTS inbound_plan_customer_scope_assertion; +CREATE TEMPORARY TABLE inbound_plan_customer_scope_assertion ( + is_valid TINYINT NOT NULL, + CONSTRAINT chk_inbound_plan_customer_scope_ready CHECK (is_valid=1) +); +INSERT INTO inbound_plan_customer_scope_assertion(is_valid) +SELECT 0 +WHERE EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id); +DROP TEMPORARY TABLE inbound_plan_customer_scope_assertion; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code, + l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) + OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', + completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(container_no,status), KEY idx_ll_receiving_operator(operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', 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 '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no), + KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称不可变快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 COMMENT '采购业务占用箱数', + version BIGINT NOT NULL DEFAULT 0 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), + UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (customer_id,inbound_code), + KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at) +) ENGINE=InnoDB COMMENT='客户入仓单与海外目的仓维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT NULL COMMENT '仓库库位编码', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (customer_id,pallet_id), + KEY idx_ll_inventory_position_location (customer_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称不可变快照', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', sku_code VARCHAR(128) NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NULL COMMENT '箱唛', carton_packages BIGINT NULL COMMENT '箱内箱数', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (customer_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (customer_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (customer_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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), UNIQUE KEY uk_ll_inventory_operation_key (customer_id,idempotency_key), + KEY idx_ll_inventory_operation_status (customer_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码', + source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', + batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', + inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', + carrier_name VARCHAR(255) NULL COMMENT '承运商名称', + route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', + fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', + created_by VARCHAR(128) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no), + KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), + KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', + order_no VARCHAR(64) NULL COMMENT '订单号', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', + inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (customer_id,order_id), + KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_at DATETIME(3) 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), + UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) + ) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_draft ( + id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', + customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID', + plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间', + released_at DATETIME(3) 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), UNIQUE KEY uk_ll_dispatch_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status) + ) ENGINE=InnoDB COMMENT='智能排车草稿'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container ( + id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID', + draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号', + capacity INT 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_dispatch_draft_container_token(draft_token) + ) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘'; + + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_container_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id')); + PREPARE dispatch_draft_container_column_stmt FROM @ddl; EXECUTE dispatch_draft_container_column_stmt; DEALLOCATE PREPARE dispatch_draft_container_column_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + warehouse_id VARCHAR(128) NOT NULL COMMENT 'Token绑定的海外仓库ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) 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), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_ll_dispatch_pda_operation_key (customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) 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 '更新时间', + outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', + recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), + KEY idx_ll_outbound_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', + pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) 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), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), + UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', + picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU''')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER lx_codes')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''派车单ID,一件代发为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''派车单号,一件代发为空''')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; + +-- Customer warehouse transfer: generated tail order plus selected historical order/inbound bindings. +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键', + allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希', + outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键', + outbound_request_hash CHAR(64) NULL COMMENT '出库请求哈希', + outbound_at DATETIME(3) NULL COMMENT '出库时间', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_no (customer_id,transfer_no), + UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id), + KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); +PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希'' AFTER allocation_idempotency_key')); +PREPARE transfer_allocation_hash_stmt FROM @ddl; EXECUTE transfer_allocation_hash_stmt; DEALLOCATE PREPARE transfer_allocation_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键'' AFTER allocation_request_hash')); +PREPARE transfer_outbound_key_stmt FROM @ddl; EXECUTE transfer_outbound_key_stmt; DEALLOCATE PREPARE transfer_outbound_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); +PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); +PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id), + KEY idx_ll_wh_transfer_source_list (customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', + shipped_at DATETIME(3) NULL COMMENT '出库时间', + received_at DATETIME(3) 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), + UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key), + KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', + destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key), + KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +-- Customer relabel application: header, item snapshots, and immutable process history. +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', + notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '换标费用CNY', + charge_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + wallet_status VARCHAR(32) NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', + wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_application_no (customer_id,relabel_no), + UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_list (customer_id,status,created_at), + KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), + KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码(选填)', + product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + total_package_count INT NOT NULL COMMENT '总箱数', + notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', + tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', + new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', + remark VARCHAR(1000) NULL COMMENT '货物明细备注', + relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', + relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', + fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_item_application (customer_id,application_id), + KEY idx_ll_relabel_item_sku (customer_id,sku_code), + KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); 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_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); 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_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); 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_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', + reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', + relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', + exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', + completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', + review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', + submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', + version INT NOT NULL DEFAULT 0 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), + UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), + KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', + result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', + reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), + KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', + from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no), + UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), + KEY idx_ll_dropship_carton_status(customer_id,status), KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; + +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT 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), UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code), + KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', + reviewed_at DATETIME(3) 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), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', + domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', + overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', + reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购操作流水'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', + origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', + destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + package_count INT NULL COMMENT '可选箱数,零或空值均有效', + received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', + allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', + currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + requested_date DATE NOT NULL COMMENT '要求服务日期', + status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), + KEY idx_ll_us_truck_order_list(customer_id,status,created_at), + KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', + order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', + sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', + package_count INT NULL COMMENT '托盘箱数', + received_at DATETIME(3) 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), + UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), + UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', + pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), + UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id), + UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), + KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板操作流水'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_exception_order ( + id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号', + exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因', + last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码', + delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号', + handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果', + evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID', + handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', + source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID', + exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间', + version BIGINT NOT NULL DEFAULT 0 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), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound(customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='海外仓异常订单'; + +CREATE TABLE IF NOT EXISTS ll_exception_return_item ( + id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', + sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', + approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + KEY idx_ll_exception_return_order(customer_id,exception_order_id) +) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_exception_order_command ( + id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希', + result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key), + KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) +) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; + +CREATE TABLE IF NOT EXISTS ll_customer_visit ( + id BIGINT NOT NULL COMMENT '客户回访记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', + contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', + destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', + visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', + customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', + remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', + attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', + visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', + visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', + visit_count INT NOT NULL DEFAULT 0 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), + UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) +) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (aggregate_type,aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); +SET @platform_admin_overseas_authorities = JSON_ARRAY( + 'platform:finance:bill:adjust', + 'platform:finance:bill:update', + 'platform:finance:bill:void', + 'platform:finance:cost:create', + 'platform:finance:cost:view', + 'platform:finance:customer-account:adjust', + 'platform:finance:customer-account:export', + 'platform:finance:customer-account:list', + 'platform:finance:customer-arap:export', + 'platform:finance:customer-arap:list', + 'platform:finance:customer-statement:export', + 'platform:finance:customer-statement:list', + 'platform:finance:customer-terms:update', + 'platform:finance:customer-terms:view', + 'platform:finance:fee-source:edit', + 'platform:finance:fee-source:review', + 'platform:finance:payment:create', + 'platform:finance:receipt:create', + 'platform:finance:receipt:view', + 'platform:finance:receivable-statistics:export', + 'platform:finance:receivable-statistics:list', + 'platform:finance:recharge:review', + 'platform:finance:reconciliation:adjust', + 'platform:finance:reconciliation:create', + 'platform:finance:reconciliation:issue', + 'platform:finance:reconciliation:review', + 'platform:finance:reconciliation:void', + 'platform:finance:settlement-ap:create', + 'platform:finance:settlement-ap:export', + 'platform:finance:settlement-ap:list', + 'platform:finance:settlement-ar:create', + 'platform:finance:settlement-ar:export', + 'platform:finance:settlement-ar:list', + 'platform:finance:settlement:void', + 'platform:overseas-business:looseboard:create', + 'platform:overseas-business:looseboard:edit', + 'platform:overseas-business:looseboard:list', + 'platform:overseas-business:looseboard:status', + 'platform:overseas-business:purchase:edit', + 'platform:overseas-business:purchase:list', + 'platform:overseas-business:purchase:review', + 'platform:overseas-business:truck:create', + 'platform:overseas-business:truck:edit', + 'platform:overseas-business:truck:list', + 'platform:overseas-business:truck:status', + 'platform:overseas-master-data:exchange-rate:create', + 'platform:overseas-master-data:exchange-rate:edit', + 'platform:overseas-master-data:exchange-rate:export', + 'platform:overseas-master-data:exchange-rate:list', + 'platform:overseas-master-data:exchange-rate:status', + 'platform:overseas-master-data:quote:create', + 'platform:overseas-master-data:quote:edit', + 'platform:overseas-master-data:quote:export', + 'platform:overseas-master-data:quote:list', + 'platform:overseas-master-data:quote:status', + 'platform:overseas-master-data:vehicle:create', + 'platform:overseas-master-data:vehicle:edit', + 'platform:overseas-master-data:vehicle:export', + 'platform:overseas-master-data:vehicle:list', + 'platform:overseas-master-data:vehicle:status', + 'platform:overseas-warehouse:carton:create', + 'platform:overseas-warehouse:carton:edit', + 'platform:overseas-warehouse:carton:list', + 'platform:overseas-warehouse:customer-visit:edit', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:inbound-order:create', + 'platform:overseas-warehouse:inbound-order:edit', + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:inbound-order:smart-dispatch', + 'platform:overseas-warehouse:inventory:adjust', + 'platform:overseas-warehouse:inventory:edit', + 'platform:overseas-warehouse:inventory:export', + 'platform:overseas-warehouse:inventory:list', + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:last-mile-order:export', + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:location:create', + 'platform:overseas-warehouse:location:delete', + 'platform:overseas-warehouse:location:edit', + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:pallet:create', + 'platform:overseas-warehouse:pallet:delete', + 'platform:overseas-warehouse:pallet:edit', + 'platform:overseas-warehouse:pallet:list', + 'platform:overseas-warehouse:product:create', + 'platform:overseas-warehouse:product:edit', + 'platform:overseas-warehouse:product:list', + 'platform:overseas-warehouse:relabel-order:create', + 'platform:overseas-warehouse:relabel-order:list', + 'platform:overseas-warehouse:relabel-order:status', + 'platform:overseas-warehouse:warehouse:create', + 'platform:overseas-warehouse:warehouse:edit', + 'platform:overseas-warehouse:warehouse:list', + 'platform:overseas-warehouse:warehouse:status' +); +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, + SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +UPDATE ll_security_projection +SET roles_json=@platform_admin_overseas_roles, + authorities_json=@platform_admin_overseas_authorities, + payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + updated_at=CURRENT_TIMESTAMP(3) +WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 + AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; + + +CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', + command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_wallet_command_result (command_event_id), + KEY idx_ll_wallet_event_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金结果幂等收件箱'; + + + +CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( + customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + business_date DATE NOT NULL COMMENT '北京时间业务日期', + sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (customer_id,business_date), + CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) +) ENGINE=InnoDB COMMENT='海外采购客户日序列'; + + + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', + created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) +) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) 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), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') +) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; + + + +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost ( + id BIGINT NOT NULL COMMENT '主键ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '来源柜次业务ID', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', warehouse_id BIGINT NULL COMMENT '海外仓ID', + pallet_count INT NOT NULL COMMENT '实际打托数', total_actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收总体积', total_cost DECIMAL(20,2) NOT NULL COMMENT '打托总成本', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + status VARCHAR(16) NOT NULL COMMENT '生成状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_destuff_cost_booking(source_container_booking_id), CONSTRAINT chk_ll_destuff_cost CHECK(pallet_count>0 AND total_actual_volume_cbm>0 AND total_cost>0 AND currency='CNY') +) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总'; +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( + id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积', + allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), + CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) +) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; + +-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG + +-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; +-- This script is run through warehouse-overseas-service's datasource only. +-- The datasource guard requires the selected schema to be baiyue_lastlag_warehouse; +-- database creation and USE are intentionally forbidden here so a miswired +-- service account cannot create or switch schemas during startup. + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (customer_id, warehouse_id, location_code, deleted), + KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT '所属FBA仓编码', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (customer_id, warehouse_id, pallet_code, deleted), + KEY idx_pallet_query (customer_id, warehouse_id, status, deleted), + KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE pallet_fba_warehouse_stmt FROM @ddl; +EXECUTE pallet_fba_warehouse_stmt; +DEALLOCATE PREPARE pallet_fba_warehouse_stmt; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID', + reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) DEFAULT 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), + UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key), + KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status), + KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status), + KEY idx_resource_lock_business (customer_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='仓储资源锁定记录'; + +-- warehouse_id references the domestic warehouse-service authority. This overseas database does +-- not duplicate warehouse master, address or capability tables and has no cross-database FK. + +-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。 +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id), + UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; + +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + +CREATE TABLE IF NOT EXISTS wh_pallet_batch ( + id BIGINT NOT NULL COMMENT '批量托盘预留ID', + warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', + request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', + attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数', + confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', + confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', + compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', + confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间', + compensated_at DATETIME(3) 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), + UNIQUE KEY uk_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录'; + +CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( + id BIGINT NOT NULL COMMENT '批量托盘明细ID', + batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID', + customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', + warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', + inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID', + pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', + location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', + status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + 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_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key), + 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='柜级入库计划批量托盘明细与资源锁关联'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '所属FBA仓编码', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +-- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE + +-- BEGIN UNIFIED OVERSEAS INCREMENTAL AND FINAL ASSERTIONS CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_event_bridge_overseas; @@ -623,7 +3074,7 @@ CALL assert_pending_orders_do_not_use_source_warehouse(); DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', @@ -632,11 +3083,11 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), - KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) + KEY idx_ll_receiving_container(container_no,status), KEY idx_ll_receiving_operator(operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'), - 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id')); + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER id')); PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt; SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'), 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id')); @@ -697,9 +3148,20 @@ SET active_operator_key=CONCAT('OPERATOR:',operator_key), active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no))) WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL; ALTER TABLE ll_receiving_container_session - MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND index_name='idx_ll_receiving_container'), + 'ALTER TABLE ll_receiving_container_session DROP INDEX idx_ll_receiving_container', 'SELECT 1')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND index_name='idx_ll_receiving_operator'), + 'ALTER TABLE ll_receiving_container_session DROP INDEX idx_ll_receiving_operator', 'SELECT 1')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +ALTER TABLE ll_receiving_container_session + ADD KEY idx_ll_receiving_container(container_no,status), + ADD KEY idx_ll_receiving_operator(operator_key,created_at); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='customer_id'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN customer_id', 'SELECT 1')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', @@ -716,7 +3178,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( ) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( - id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), @@ -753,6 +3215,9 @@ SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1', 'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)', 'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)')); PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' AND column_name='customer_id'), + 'ALTER TABLE ll_receiving_container_operation DROP COLUMN customer_id', 'SELECT 1')); +PREPARE phase4_receiving_operation_scope_stmt FROM @ddl; EXECUTE phase4_receiving_operation_scope_stmt; DEALLOCATE PREPARE phase4_receiving_operation_scope_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); @@ -842,7 +3307,6 @@ PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCA -- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。 CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', @@ -864,20 +3328,27 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( 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(container_booking_id), - KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_candidate(container_no,status), KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; INSERT IGNORE INTO ll_container_shipping_gate ( - container_booking_id,customer_id,ocean_booking_id,container_no,status, + container_booking_id,ocean_booking_id,container_no,status, node_sequence,aggregate_version,handover_version,created_at,updated_at ) -SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY', +SELECT source_container_booking_id,'',container_no,'AWAITING_PICKUP_READY', 0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3) FROM ll_order WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> '' AND container_no IS NOT NULL AND container_no <> '' -GROUP BY source_container_booking_id,customer_id,container_no; +GROUP BY source_container_booking_id,container_no; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_container_shipping_gate' AND index_name='idx_ll_shipping_gate_candidate'), + 'ALTER TABLE ll_container_shipping_gate DROP INDEX idx_ll_shipping_gate_candidate', 'SELECT 1')); +PREPARE phase4_shipping_gate_scope_stmt FROM @ddl; EXECUTE phase4_shipping_gate_scope_stmt; DEALLOCATE PREPARE phase4_shipping_gate_scope_stmt; +ALTER TABLE ll_container_shipping_gate ADD KEY idx_ll_shipping_gate_candidate(container_no,status); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_shipping_gate' AND column_name='customer_id'), + 'ALTER TABLE ll_container_shipping_gate DROP COLUMN customer_id', 'SELECT 1')); +PREPARE phase4_shipping_gate_scope_stmt FROM @ddl; EXECUTE phase4_shipping_gate_scope_stmt; DEALLOCATE PREPARE phase4_shipping_gate_scope_stmt; CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( id BIGINT NOT NULL COMMENT 'PDA作业历史ID', @@ -1713,9 +4184,18 @@ INSERT IGNORE INTO ll_security_projection ( ) VALUES ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256), 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), - ('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + ('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, + SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); +UPDATE ll_security_projection +SET roles_json=@platform_admin_overseas_roles, + authorities_json=@platform_admin_overseas_authorities, + payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + updated_at=CURRENT_TIMESTAMP(3) +WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 + AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; + DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; DELIMITER $$ CREATE PROCEDURE assert_lastleg_security_projection_ready() @@ -1724,6 +4204,22 @@ BEGIN AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; END IF; + IF EXISTS ( + SELECT 1 FROM ll_security_projection + WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 + AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0' + AND (JSON_LENGTH(roles_json) <> 1 + OR NOT JSON_CONTAINS(roles_json, JSON_QUOTE('PLATFORM_ADMIN')) + OR JSON_LENGTH(authorities_json) <> 109 + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:last-mile-order:list')) + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:inbound-order:list')) + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:dispatch:list')) + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-business:purchase:list')) + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-master-data:quote:list')) + OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:finance:fee-source:review'))) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM固定管理员version=0授权快照不完整'; + END IF; END$$ DELIMITER ; CALL assert_lastleg_security_projection_ready(); @@ -1917,20 +4413,6 @@ DROP PROCEDURE assert_receiving_warehouse_assignment_ready; SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message; --- 海外 PDA 接柜、作业与船节点均按柜级建模;客户归属保留在柜内各入仓计划。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_receiving_container_session' AND column_name='customer_id'), - 'ALTER TABLE ll_receiving_container_session DROP COLUMN customer_id', 'SELECT 1')); -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_receiving_container_operation' AND column_name='customer_id'), - 'ALTER TABLE ll_receiving_container_operation DROP COLUMN customer_id', 'SELECT 1')); -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_container_shipping_gate' AND column_name='customer_id'), - 'ALTER TABLE ll_container_shipping_gate DROP COLUMN customer_id', 'SELECT 1')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -- 一件代发来源托盘与海外仓实物托盘分离,实物绑定不得覆盖来源身份。 USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index ade5b0d87..a2c971e26 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -39,6 +39,12 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107230,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:carton:list','海外箱号','/overseas-warehouse/cartons','GET',930,'ACTIVE',1,0), + (5107231,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:create','创建海外箱号',NULL,'POST',931,'ACTIVE',1,0), + (5107232,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:edit','编辑海外箱号',NULL,'PUT',932,'ACTIVE',1,0), + (5107240,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:product:list','海外产品库','/overseas-warehouse/products','GET',940,'ACTIVE',1,0), + (5107241,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:create','创建海外产品',NULL,'POST',941,'ACTIVE',1,0), + (5107242,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:edit','编辑海外产品',NULL,'PUT',942,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index dbe8ac3c4..c57fb819a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -93,7 +93,7 @@ - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 -- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 +- 开发/测试空库基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;AUTHORIZATION 必须携带当前 `PLATFORM_ADMIN` 角色及按 IAM 跨区前缀筛选的完整权限快照,统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,后续 IAM version>=1 事件单调覆盖。不得扩大为其他历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c3af13fd4..4d39e0b4f 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2058,16 +2058,137 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; --- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); +SET @platform_admin_overseas_authorities = JSON_ARRAY( + 'platform:finance:bill:adjust', + 'platform:finance:bill:update', + 'platform:finance:bill:void', + 'platform:finance:cost:create', + 'platform:finance:cost:view', + 'platform:finance:customer-account:adjust', + 'platform:finance:customer-account:export', + 'platform:finance:customer-account:list', + 'platform:finance:customer-arap:export', + 'platform:finance:customer-arap:list', + 'platform:finance:customer-statement:export', + 'platform:finance:customer-statement:list', + 'platform:finance:customer-terms:update', + 'platform:finance:customer-terms:view', + 'platform:finance:fee-source:edit', + 'platform:finance:fee-source:review', + 'platform:finance:payment:create', + 'platform:finance:receipt:create', + 'platform:finance:receipt:view', + 'platform:finance:receivable-statistics:export', + 'platform:finance:receivable-statistics:list', + 'platform:finance:recharge:review', + 'platform:finance:reconciliation:adjust', + 'platform:finance:reconciliation:create', + 'platform:finance:reconciliation:issue', + 'platform:finance:reconciliation:review', + 'platform:finance:reconciliation:void', + 'platform:finance:settlement-ap:create', + 'platform:finance:settlement-ap:export', + 'platform:finance:settlement-ap:list', + 'platform:finance:settlement-ar:create', + 'platform:finance:settlement-ar:export', + 'platform:finance:settlement-ar:list', + 'platform:finance:settlement:void', + 'platform:overseas-business:looseboard:create', + 'platform:overseas-business:looseboard:edit', + 'platform:overseas-business:looseboard:list', + 'platform:overseas-business:looseboard:status', + 'platform:overseas-business:purchase:edit', + 'platform:overseas-business:purchase:list', + 'platform:overseas-business:purchase:review', + 'platform:overseas-business:truck:create', + 'platform:overseas-business:truck:edit', + 'platform:overseas-business:truck:list', + 'platform:overseas-business:truck:status', + 'platform:overseas-master-data:exchange-rate:create', + 'platform:overseas-master-data:exchange-rate:edit', + 'platform:overseas-master-data:exchange-rate:export', + 'platform:overseas-master-data:exchange-rate:list', + 'platform:overseas-master-data:exchange-rate:status', + 'platform:overseas-master-data:quote:create', + 'platform:overseas-master-data:quote:edit', + 'platform:overseas-master-data:quote:export', + 'platform:overseas-master-data:quote:list', + 'platform:overseas-master-data:quote:status', + 'platform:overseas-master-data:vehicle:create', + 'platform:overseas-master-data:vehicle:edit', + 'platform:overseas-master-data:vehicle:export', + 'platform:overseas-master-data:vehicle:list', + 'platform:overseas-master-data:vehicle:status', + 'platform:overseas-warehouse:carton:create', + 'platform:overseas-warehouse:carton:edit', + 'platform:overseas-warehouse:carton:list', + 'platform:overseas-warehouse:customer-visit:edit', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:inbound-order:create', + 'platform:overseas-warehouse:inbound-order:edit', + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:inbound-order:smart-dispatch', + 'platform:overseas-warehouse:inventory:adjust', + 'platform:overseas-warehouse:inventory:edit', + 'platform:overseas-warehouse:inventory:export', + 'platform:overseas-warehouse:inventory:list', + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:last-mile-order:export', + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:location:create', + 'platform:overseas-warehouse:location:delete', + 'platform:overseas-warehouse:location:edit', + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:pallet:create', + 'platform:overseas-warehouse:pallet:delete', + 'platform:overseas-warehouse:pallet:edit', + 'platform:overseas-warehouse:pallet:list', + 'platform:overseas-warehouse:product:create', + 'platform:overseas-warehouse:product:edit', + 'platform:overseas-warehouse:product:list', + 'platform:overseas-warehouse:relabel-order:create', + 'platform:overseas-warehouse:relabel-order:list', + 'platform:overseas-warehouse:relabel-order:status', + 'platform:overseas-warehouse:warehouse:create', + 'platform:overseas-warehouse:warehouse:edit', + 'platform:overseas-warehouse:warehouse:list', + 'platform:overseas-warehouse:warehouse:status' +); INSERT IGNORE INTO ll_security_projection ( aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, payload_hash,last_event_id,changed_at,updated_at ) VALUES ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256), 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), - ('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + ('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, + SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); +UPDATE ll_security_projection +SET roles_json=@platform_admin_overseas_roles, + authorities_json=@platform_admin_overseas_authorities, + payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + updated_at=CURRENT_TIMESTAMP(3) +WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 + AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; + CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java index 76bf71249..03f575d40 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -23,11 +23,21 @@ class LastlegSecurityContractTest { "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT", "roles_json JSON", "authorities_json JSON"); for (String sql : new String[]{baseline, migration}) { - assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", - "('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", + assertThat(sql).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')", + "SET @platform_admin_overseas_authorities = JSON_ARRAY(", + "('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", + "('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0", "baseline-platform-admin-user-v0", - "baseline-platform-admin-authorization-v0") - .doesNotContain("baseline-merchant-admin"); + "baseline-platform-admin-authorization-v0", + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:dispatch:list", + "platform:overseas-business:purchase:list", + "platform:overseas-master-data:quote:list", + "platform:finance:fee-source:review", + "AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'") + .doesNotContain("('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", + "baseline-merchant-admin"); } assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", "default_app_code"); diff --git a/scripts/README.md b/scripts/README.md index 69aba35e5..d00aa01d3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -235,18 +235,19 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令 ### 本地 MySQL 全量验收 -正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA` 和 15403 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 +正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;任何非本机地址都会立即以退出码 2 拒绝。非 dry-run 默认连续执行两轮,每轮精确删除 12 个 `baiyue_*` 国内/海外业务库,再只执行 `deploy/sql/头程数据库统一增量-MySQL8.sql` 和 `deploy/sql/海外区域统一增量-MySQL8.sql`,最后核对两个文件的最终 `PASS` 与 12 库完整性。 ```powershell .\scripts\local-db-full-acceptance.ps1 -DryRun .\scripts\local-db-full-acceptance.ps1 +.\scripts\local-db-full-acceptance.ps1 -Rounds 1 ``` -Dry-run 只校验 SQL 输入并打印空库、复跑、两类 picking 旧结构、WAIT_SPLIT/IAM、海外仓旧结构、中断恢复和最终结构的执行顺序,不启动 MySQL、不写库。真实运行每阶段写入 `.codex-tmp/p23-04-acceptance/acceptance-run-*.log`,不使用 `--force`,任一 SQL 错误立即停止并透传退出码,最后执行 `verify-final.sql`。脚本不保存或传入数据库密码,使用本机默认认证环境。 +Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行数、清理范围和执行顺序,不连接 MySQL。真实运行会先把中文文件名按原始字节暂存为英文文件名,规避 Windows MySQL 客户端的 `source` 路径问题;每轮日志写入 `.codex-tmp/local-db-full-acceptance/round-*/`。脚本不保存数据库密码,认证由本机登录配置或当前进程的 `MYSQL_PWD` 等外部方式提供。成功后保留第二轮完整初始化结果;失败时可能留下半初始化库,下次运行会先清理精确范围再重建。 仓库中 `phase2-test-env-acceptance.ps1`、`admin-test-env-acceptance.mjs`、`client-test-env-acceptance.mjs`、`export-center-test-env-acceptance.ps1` 等属于测试环境/API 验收脚本;它们可能读取或写入测试环境数据,不由本入口调用。`migrate-local-qualification-file.ps1`、`local-env.ps1` 等是环境/迁移辅助脚本,也不由本入口调用。 -本入口不读取数据库凭据、不执行 MySQL、HTTP、迁移、DDL、INSERT/UPDATE/DELETE,也不启动 Nacos、Gateway 或业务服务。需要数据库矩阵时必须单独运行受控验收流程。 +本入口只操作明确指定的本机 MySQL,不访问开发或测试 HTTP 环境,不启动 Nacos、Gateway 或业务服务,也不会清理系统库或非 `baiyue_*` 数据库。 ## 退出码 diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 2bc64aff1..a5a501293 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -3,6 +3,13 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') +const overseasSql = readFileSync(new URL('../../deploy/sql/海外区域统一增量-MySQL8.sql', import.meta.url), 'utf8') +const lastlegBaseline = readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') +const lastlegPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8') +const phase5DeliveryPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8') +const overseasWarehousePermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-warehouse/permissions.ts', import.meta.url), 'utf8') +const overseasBusinessPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-business/permissions.ts', import.meta.url), 'utf8') +const overseasMasterDataPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-master-data/permissions.ts', import.meta.url), 'utf8') const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8') const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') @@ -50,6 +57,81 @@ test('国内统一初始化支持真实空库且不越界创建海外业务库', ) }) +test('国内统一初始化在注释门禁前创建订单申报快照表', () => { + for (const table of ['fl_order_declaration_snapshot', 'fl_order_declaration_event']) { + const createIndex = sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table} (`) + const commentIndex = sql.indexOf(`CALL queue_table_comment('baiyue_firstleg', '${table}'`) + assert.ok(createIndex >= 0, `missing domestic baseline table: ${table}`) + assert.ok(commentIndex >= 0, `missing domestic table comment gate: ${table}`) + assert.ok(createIndex < commentIndex, `${table} must exist before the table-comment gate runs`) + } +}) + +test('海外统一初始化内联空库基线且不依赖模块 SQL', () => { + assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/) + assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/) + assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg/) + assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse/) + assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS ll_order \(/) + assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS wh_location \(/) + assert.ok( + overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order (') < + overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order_source_pallet ('), + 'll_order must exist before the source-pallet foreign key is created', + ) + assert.ok( + overseasSql.indexOf('CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse') < + overseasSql.indexOf('CREATE TABLE IF NOT EXISTS wh_location ('), + 'the overseas warehouse schema must be selected before its baseline tables are created', + ) +}) + +test('海外空库基线为固定平台管理员提供完整version0授权投影', () => { + const authorityBlock = lastlegBaseline.match( + /SET @platform_admin_overseas_authorities = JSON_ARRAY\(([\s\S]*?)\n\);/, + )?.[1] + assert.ok(authorityBlock, 'missing platform_admin overseas authority snapshot') + const authorities = [...authorityBlock.matchAll(/'([^']+)'/g)].map((match) => match[1]) + assert.equal(authorities.length, 109) + assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates') + assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34) + assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11) + assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15) + assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 49) + for (const authority of authorities) { + assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`) + } + for (const source of [overseasWarehousePermissions, overseasBusinessPermissions, overseasMasterDataPermissions]) { + const declared = [...source.matchAll(/'((?:platform:overseas-warehouse|platform:overseas-business|platform:overseas-master-data):[^']+)'/g)] + .map((match) => match[1]) + for (const authority of declared) { + assert.ok( + [lastlegPermissionSeed, phase5DeliveryPermissionSeed].some((seed) => seed.includes(`'${authority}'`)), + `Admin permission is absent from the IAM source seed: ${authority}`, + ) + assert.ok(authorities.includes(authority), `Admin permission is absent from the fixed-admin overseas snapshot: ${authority}`) + } + } + for (const required of [ + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-business:purchase:list', + 'platform:overseas-master-data:quote:list', + 'platform:finance:fee-source:review', + ]) assert.ok(authorities.includes(required), `missing fixed-admin overseas authority: ${required}`) + + assert.match(lastlegBaseline, /SET @platform_admin_overseas_roles = JSON_ARRAY\('PLATFORM_ADMIN'\)/) + assert.doesNotMatch(lastlegBaseline, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/) + assert.doesNotMatch(overseasSql, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/) + + const inline = overseasSql.match( + /-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG/, + )?.[1] + assert.ok(inline, 'missing inline Lastleg baseline') + assert.equal(inline.replace(/\r\n/g, '\n').trim(), lastlegBaseline.replace(/\r\n/g, '\n').trim()) +}) + test('国内统一初始化补齐入库费用人工调整基线字段', () => { assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/) assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 733ad8e4a..dbb65e998 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -2,92 +2,128 @@ param( [switch]$DryRun, [Alias('Host')][string]$TargetHost = '127.0.0.1', [int]$Port = 3306, - [string]$InputPath, [string]$MysqlExe = 'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe', - [string]$User = 'root' + [string]$User = 'root', + [ValidateRange(1, 10)][int]$Rounds = 2 ) $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path -$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '0EE13427AB60CECCEB42985CF1E78A05645C18CD9BF2886110C0AFEAF3CA7B9B' -$ExpectedLines = 15674 +$ReportRoot = Join-Path $Workspace '.codex-tmp\local-db-full-acceptance' +$DomesticSql = Join-Path $Repo 'deploy\sql\头程数据库统一增量-MySQL8.sql' +$OverseasSql = Join-Path $Repo 'deploy\sql\海外区域统一增量-MySQL8.sql' +$Schemas = @( + 'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', + 'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse', + 'baiyue_event_bridge_overseas' +) if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') exit 2 } -if ([string]::IsNullOrWhiteSpace($InputPath)) { - $InputPath = (Get-ChildItem (Join-Path $Repo 'deploy\sql') -Filter '*MySQL8.sql' -File | - Where-Object { $_.Length -gt 100000 } | Select-Object -First 1).FullName -} -if (-not (Test-Path -LiteralPath $InputPath)) { - [Console]::Error.WriteLine("InputPath does not exist: $InputPath") +if (-not (Test-Path -LiteralPath $MysqlExe)) { + [Console]::Error.WriteLine("mysql executable does not exist: $MysqlExe") exit 2 } -$actual = ((Get-FileHash -Algorithm SHA256 -LiteralPath $InputPath).Hash).Trim().ToUpperInvariant() -$lines = [System.IO.File]::ReadAllLines($InputPath).Length -if ($actual -ne $ExpectedSha256 -or $lines -ne $ExpectedLines) { - [Console]::Error.WriteLine("Unified SQL snapshot mismatch: $actual/$lines") - exit 2 -} -$sqlText = [System.IO.File]::ReadAllText($InputPath) -$commentGateTokens = @( - 'CREATE PROCEDURE queue_column_comment', - 'CREATE PROCEDURE apply_one_table_comments', - 'CALL apply_queued_comments();', - '字段注释批量升级改变了结构签名', - '存在缺失中文注释的业务表', - '存在缺失中文注释的业务字段' -) -foreach ($token in $commentGateTokens) { - if (-not $sqlText.Contains($token)) { - [Console]::Error.WriteLine("Unified SQL comment acceptance marker missing: $token") +foreach ($sqlFile in @($DomesticSql, $OverseasSql)) { + if (-not (Test-Path -LiteralPath $sqlFile)) { + [Console]::Error.WriteLine("Unified SQL does not exist: $sqlFile") exit 2 } } -if ([regex]::Matches($sqlText, "SELECT 'PASS' AS migration_status").Count -ne 1) { - [Console]::Error.WriteLine('Unified SQL must contain exactly one final PASS statement') + +$DomesticText = [IO.File]::ReadAllText($DomesticSql) +$OverseasText = [IO.File]::ReadAllText($OverseasSql) +if (-not $DomesticText.Contains("SELECT 'PASS' AS migration_status")) { + [Console]::Error.WriteLine('Domestic unified SQL is missing its final PASS statement.') exit 2 } +foreach ($marker in @( + '-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG', + '-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE', + "SELECT 'PASS' AS migration_status" +)) { + if (-not $OverseasText.Contains($marker)) { + [Console]::Error.WriteLine("Overseas unified SQL is missing marker: $marker") + exit 2 + } +} -$sequence = @( - 'empty: base.sql', 'replay: base.sql + input.sql + input.sql', - 'smart-old: fixture-smart-old.sql', 'inbound-old: fixture-inbound-old.sql', - 'waitsplit-iam: fixture-test-shape.sql', 'overseas-old: fixture-overseas-old.sql', - 'overseas-missing-objects: fixture-overseas-missing-objects.sql', - 'interrupted-recovery: fixture-interrupted.sql', 'final-structure: verify-final.sql') -Write-Output "SQL SHA-256: $actual / lines: $lines" -Write-Output 'Comment gate: queued validation, per-table ALTER, signature verification, nine-schema coverage, unique PASS' +$DomesticHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $DomesticSql).Hash +$OverseasHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $OverseasSql).Hash Write-Output "Target: $TargetHost`:$Port" -Write-Output 'Fixture order:' -$sequence | ForEach-Object { Write-Output " $_" } -if ($DryRun) { Write-Output 'DRY-RUN PASS (mysql not started; no database writes)'; exit 0 } +Write-Output "Rounds: $Rounds" +Write-Output "Domestic SQL: $DomesticHash / $([IO.File]::ReadAllLines($DomesticSql).Length) lines" +Write-Output "Overseas SQL: $OverseasHash / $([IO.File]::ReadAllLines($OverseasSql).Length) lines" +Write-Output "Reset scope: $($Schemas -join ', ')" +Write-Output 'Execution order per round: clear exact schemas -> domestic unified SQL -> overseas unified SQL -> final PASS and schema checks' +if ($DryRun) { + Write-Output 'DRY-RUN PASS (mysql not contacted; no database writes)' + exit 0 +} -$acceptanceInput = Join-Path $Acceptance 'input.sql' -Copy-Item -LiteralPath $InputPath -Destination $acceptanceInput -Force -$stagedHash = ((Get-FileHash -Algorithm SHA256 -LiteralPath $acceptanceInput).Hash).Trim().ToUpperInvariant() -if ($stagedHash -ne $actual) { - [Console]::Error.WriteLine("Failed to stage the validated unified SQL snapshot: $stagedHash") +New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null +$StagedDomestic = Join-Path $ReportRoot 'domestic.sql' +$StagedOverseas = Join-Path $ReportRoot 'overseas.sql' +Copy-Item -LiteralPath $DomesticSql -Destination $StagedDomestic -Force +Copy-Item -LiteralPath $OverseasSql -Destination $StagedOverseas -Force +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $StagedDomestic).Hash -ne $DomesticHash -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $StagedOverseas).Hash -ne $OverseasHash) { + [Console]::Error.WriteLine('Failed to stage byte-identical SQL files under ASCII filenames.') exit 2 } -$run = 0 -function Invoke-Stage([string]$Name, [string]$File) { - $script = Join-Path $Acceptance $File - $log = Join-Path $Acceptance ("acceptance-run-{0:D2}-{1}.log" -f $script:run, $Name) - $script:run++ - if (-not (Test-Path -LiteralPath $script)) { [Console]::Error.WriteLine("Script does not exist: $script"); return 2 } - & $MysqlExe '--protocol=TCP' "--host=$TargetHost" "--port=$Port" "--user=$User" '--database=mysql' '--batch' ("--execute=source " + $script.Replace('\','/')) *> $log - return $LASTEXITCODE +$MysqlCommon = @( + '--protocol=TCP', "--host=$TargetHost", "--port=$Port", "--user=$User", + '--default-character-set=utf8mb4', '--batch' +) +& $MysqlExe @MysqlCommon '--skip-column-names' '--execute=SELECT VERSION(), @@hostname, @@port;' | Out-Null +if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine('Cannot connect to the local MySQL target. Configure authentication outside this script, for example with MYSQL_PWD for the current process.') + exit $LASTEXITCODE } -$calls = @( - @('empty','base.sql'), @('replay-reset','base.sql'), @('replay-1','input.sql'), @('replay-2','input.sql'), - @('smart-old','fixture-smart-old.sql'), @('inbound-old','fixture-inbound-old.sql'), @('waitsplit-iam','fixture-test-shape.sql'), - @('overseas-old','fixture-overseas-old.sql'), @('overseas-missing','fixture-overseas-missing-objects.sql'), - @('interrupted','fixture-interrupted.sql'), @('final-verify','verify-final.sql')) -foreach ($call in $calls) { $code = Invoke-Stage $call[0] $call[1]; if ($code -ne 0) { exit $code } } -Write-Output 'FULL LOCAL ACCEPTANCE PASS' +$DropSql = ($Schemas | ForEach-Object { "DROP DATABASE IF EXISTS ``$_```;" }) -join ' ' +for ($round = 1; $round -le $Rounds; $round++) { + $RoundRoot = Join-Path $ReportRoot ("round-{0}" -f $round) + New-Item -ItemType Directory -Path $RoundRoot -Force | Out-Null + $Stopwatch = [Diagnostics.Stopwatch]::StartNew() + + & $MysqlExe @MysqlCommon "--execute=$DropSql" *> (Join-Path $RoundRoot '00-clear.log') + if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine("Round $round failed while clearing the exact project schemas.") + exit $LASTEXITCODE + } + + $DomesticLog = Join-Path $RoundRoot '10-domestic.log' + $DomesticSource = $StagedDomestic.Replace('\', '/') + & $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $DomesticSource" *> $DomesticLog + if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $DomesticLog -Pattern '^PASS\s+统一增量全部执行完成' -Quiet)) { + [Console]::Error.WriteLine("Round $round domestic unified SQL failed. See $DomesticLog") + exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 }) + } + + $OverseasLog = Join-Path $RoundRoot '20-overseas.log' + $OverseasSource = $StagedOverseas.Replace('\', '/') + & $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $OverseasSource" *> $OverseasLog + if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $OverseasLog -Pattern '^PASS\s+海外财务P0费用来源字段已就绪$' -Quiet)) { + [Console]::Error.WriteLine("Round $round overseas unified SQL failed. See $OverseasLog") + exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 }) + } + + $SchemaList = "'" + ($Schemas -join "','") + "'" + $SchemaCount = (& $MysqlExe @MysqlCommon '--skip-column-names' "--execute=SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name IN ($SchemaList);").Trim() + if ($LASTEXITCODE -ne 0 -or [int]$SchemaCount -ne $Schemas.Count) { + [Console]::Error.WriteLine("Round $round schema verification failed: $SchemaCount/$($Schemas.Count)") + exit 2 + } + + $Stopwatch.Stop() + Write-Output "ROUND $round PASS / $SchemaCount schemas / $([Math]::Round($Stopwatch.Elapsed.TotalSeconds, 1)) seconds" +} + +Write-Output 'FULL LOCAL TWO-REGION DATABASE ACCEPTANCE PASS' exit 0 From 87e65043f302af4980c94100fe1f73aa4ee35a53 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 10 Sep 2026 00:18:17 +0800 Subject: [PATCH 40/42] fix: treat overseas unload photos as platform evidence --- file/AGENTS.md | 2 +- .../com/baiyue/file/service/BusinessFileCategory.java | 6 +++--- .../file/service/InternalBusinessFileServiceTest.java | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/file/AGENTS.md b/file/AGENTS.md index bfa4e15d0..e432bb45e 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -15,7 +15,7 @@ | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | -| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | +| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是多客户共享柜的物理证据,固定平台范围 `tenantId=0` 并按收货会话 ID 精确校验 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index a0caa102f..f308db828 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -22,7 +22,7 @@ enum BusinessFileCategory { LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), - OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -55,8 +55,8 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { - if (this == OCEAN_BOOKING_DOCUMENT && tenantId != 0) { - throw new BizException(ResultCode.BAD_REQUEST, "订舱附件必须归属平台"); + if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属平台"); } if (customerOwned && tenantId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 9da25ae57..acc3bd48b 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -44,8 +44,6 @@ class InternalBusinessFileServiceTest { when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); - when(storage.store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored( - "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; }); @@ -54,7 +52,7 @@ class InternalBusinessFileServiceTest { var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); - var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); + var unloadPhoto = service.upload(0L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, @@ -67,12 +65,14 @@ class InternalBusinessFileServiceTest { assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId) - .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); + .containsExactly(0L, "OVERSEAS_UNLOAD_PHOTO", "51"); verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_CLEARANCE_DOCUMENT); verify(storage).store(0L, "51", file, BusinessFileCategory.LOADING_CUSTOMS_DECLARATION); verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_DELIVERY_ORDER); verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER); - verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); + verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); + assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); } @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() { From e72e74b21e79afedcddef929a47bacb67bb23ef3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 10 Sep 2026 10:25:16 +0800 Subject: [PATCH 41/42] fix: stabilize overseas initialization and PDA acceptance --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 - deploy/sql/海外区域统一增量-MySQL8.sql | 453 ++++-------------- .../main/resources/db/lastleg-permissions.sql | 8 +- ...erseasPhase4PermissionSqlContractTest.java | 2 +- .../warehouse/mapper/PdaWarehouseMapper.java | 2 +- .../lastleg/security/OverseasPermissions.java | 2 + .../src/main/resources/db/lastleg.sql | 27 +- .../delivery/DeliverySchemaContractTest.java | 2 +- .../warehouse/PdaWarehouseContractTest.java | 8 + scripts/admin-test-env-acceptance.mjs | 4 +- .../initialization-baseline-contract.test.mjs | 18 +- ...-phase4-producer-fixture-contract.test.mjs | 3 + 12 files changed, 160 insertions(+), 375 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 84e2180ba..a4d3ee7be 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16942,12 +16942,6 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), - (5107230,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:carton:list','海外箱号','/overseas-warehouse/cartons','GET',930,'ACTIVE',1,0), - (5107231,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:create','创建海外箱号',NULL,'POST',931,'ACTIVE',1,0), - (5107232,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:edit','编辑海外箱号',NULL,'PUT',932,'ACTIVE',1,0), - (5107240,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:product:list','海外产品库','/overseas-warehouse/products','GET',940,'ACTIVE',1,0), - (5107241,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:create','创建海外产品',NULL,'POST',941,'ACTIVE',1,0), - (5107242,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:edit','编辑海外产品',NULL,'PUT',942,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c435347a6..a899a53a6 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -3,6 +3,92 @@ -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 SET NAMES utf8mb4; +-- 海外 Event Bridge 空库基线必须先于业务服务表初始化。 +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录'; + +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; + + -- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG SET NAMES utf8mb4; @@ -958,7 +1044,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) ) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; @@ -1362,7 +1448,7 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息'' AFTER order_no')); PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( @@ -2064,6 +2150,21 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; +-- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); +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_security_projection' AND column_name='authorities_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; +UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; +ALTER TABLE ll_security_projection + MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; + -- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); SET @platform_admin_overseas_authorities = JSON_ARRAY( @@ -2127,9 +2228,6 @@ SET @platform_admin_overseas_authorities = JSON_ARRAY( 'platform:overseas-master-data:vehicle:export', 'platform:overseas-master-data:vehicle:list', 'platform:overseas-master-data:vehicle:status', - 'platform:overseas-warehouse:carton:create', - 'platform:overseas-warehouse:carton:edit', - 'platform:overseas-warehouse:carton:list', 'platform:overseas-warehouse:customer-visit:edit', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:delivery:documents', @@ -2166,12 +2264,11 @@ SET @platform_admin_overseas_authorities = JSON_ARRAY( 'platform:overseas-warehouse:pallet:delete', 'platform:overseas-warehouse:pallet:edit', 'platform:overseas-warehouse:pallet:list', - 'platform:overseas-warehouse:product:create', - 'platform:overseas-warehouse:product:edit', - 'platform:overseas-warehouse:product:list', 'platform:overseas-warehouse:relabel-order:create', 'platform:overseas-warehouse:relabel-order:list', 'platform:overseas-warehouse:relabel-order:status', + 'platform:overseas-warehouse:return-cartons:list', + 'platform:overseas-warehouse:return-inventory:list', 'platform:overseas-warehouse:warehouse:create', 'platform:overseas-warehouse:warehouse:edit', 'platform:overseas-warehouse:warehouse:list', @@ -2454,89 +2551,6 @@ ALTER TABLE wh_pallet -- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE -- BEGIN UNIFIED OVERSEAS INCREMENTAL AND FINAL ASSERTIONS -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge_overseas; - -CREATE TABLE IF NOT EXISTS bridge_outbox ( - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - event_version INT NOT NULL COMMENT '事件契约版本', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', - aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', - aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', - aggregate_version BIGINT NOT NULL COMMENT '聚合版本', - handover_version BIGINT NULL COMMENT '跨区交接版本', - trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', - correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', - causation_id VARCHAR(128) NULL COMMENT '上游事件标识', - occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', - payload_json JSON NOT NULL COMMENT '事件负载', - status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', - lease_owner VARCHAR(128) NULL COMMENT '租约持有者', - lease_until DATETIME(3) NULL COMMENT '租约截止时间', - last_error VARCHAR(1000) NULL COMMENT '最后错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at DATETIME(3) NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱'; - -CREATE TABLE IF NOT EXISTS bridge_inbox ( - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - event_version INT NOT NULL COMMENT '事件契约版本', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', - aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', - aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', - aggregate_version BIGINT NOT NULL COMMENT '聚合版本', - handover_version BIGINT NULL COMMENT '跨区交接版本', - trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', - correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', - causation_id VARCHAR(128) NULL COMMENT '上游事件标识', - occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', - payload_json JSON NOT NULL COMMENT '事件负载', - status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', - lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', - retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', - last_error VARCHAR(1000) NULL COMMENT '最后错误', - received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at DATETIME(3) NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱'; - -CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - attempt_no INT NOT NULL COMMENT '投递序号', - status VARCHAR(32) NOT NULL COMMENT '投递结果', - response_code VARCHAR(64) NULL COMMENT '对端响应码', - error_message VARCHAR(1000) NULL COMMENT '错误信息', - started_at DATETIME(3) NOT NULL COMMENT '开始时间', - finished_at DATETIME(3) NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录'; - -DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; DELIMITER $$ CREATE PROCEDURE assert_overseas_event_bridge_schema() BEGIN @@ -3584,263 +3598,6 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; -- 五期正式结构必须由海外固定入口自行补齐;既有四期库不得依赖当前基线或历史脚本。 -CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', - task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - warehouse_id VARCHAR(128) NOT NULL COMMENT 'Token绑定的海外仓库ID', operator_name VARCHAR(128) NULL COMMENT '操作人姓名', - pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) 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), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch(customer_id,dispatch_order_id), - UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no) -) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; -CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', - dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', - packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', - idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_dispatch_pda_pick_key(customer_id,idempotency_key), - KEY idx_ll_dispatch_pda_pick_item(customer_id,task_id,dispatch_item_id) -) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; -CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', - operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NULL COMMENT '处理结果JSON', - status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', 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_ll_dispatch_pda_operation_key(customer_id,idempotency_key) -) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; -CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', - product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY(id), KEY idx_ll_dispatch_pick_order_pick(customer_id,pick_id), - KEY idx_ll_dispatch_pick_order_receipt(customer_id,receipt_id) -) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; - -CREATE TABLE IF NOT EXISTS ll_outbound_order ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', - dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', - overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', - idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) 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 '更新时间', - outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', - lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', - recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_no(outbound_no), - UNIQUE KEY uk_ll_outbound_idempotency(customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch(customer_id,dispatch_order_id), - KEY idx_ll_outbound_list(customer_id,status,created_at) -) ENGINE=InnoDB COMMENT='尾程出库单'; -CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', - warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', - destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', - destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', - picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', - sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id), - KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code) -) ENGINE=InnoDB COMMENT='出库托盘明细'; -CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', - task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', - completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', - completed_at DATETIME(3) 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), - UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), - UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) -) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; -CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', - outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', - packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', - picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) -) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; - -CREATE TABLE IF NOT EXISTS ll_delivery_order ( - id BIGINT NOT NULL COMMENT '派送单主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', - outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号', - dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', - overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', - status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', - waybill_no VARCHAR(128) NULL COMMENT '承运运单号', - bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', - documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', - delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商', - pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型', - vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称', - driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名', - package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数', - total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', - planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', - expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', signed_at DATETIME(3) NULL COMMENT '签收时间', remark VARCHAR(1000) NULL COMMENT '派送备注', - idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', - version BIGINT NOT NULL DEFAULT 0 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), - UNIQUE KEY uk_ll_delivery_no(customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency(customer_id,idempotency_key), - UNIQUE KEY uk_ll_delivery_outbound(customer_id,outbound_order_id), KEY idx_ll_delivery_list(customer_id,status,created_at) -) ENGINE=InnoDB COMMENT='尾程派送单'; -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 '派送所属客户隔离键', - delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', - version BIGINT NOT NULL COMMENT '保存成功时派送单版本', - idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键', - request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希', - snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照', - operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID', - created_at DATETIME(3) NOT NULL COMMENT '资料保存时间', - PRIMARY KEY(id), - UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key), - UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version) -) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录'; - -CREATE TABLE IF NOT EXISTS ll_delivery_operation ( - id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', - operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', - result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id) -) ENGINE=InnoDB COMMENT='派送单操作记录'; - -CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', - warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', - inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', - idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 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), - UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no), - UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id) -) ENGINE=InnoDB COMMENT='一件代发托盘档案'; -CREATE TABLE IF NOT EXISTS ll_dropship_carton ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', - warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', - carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', - outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', version BIGINT NOT NULL DEFAULT 0 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), - UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status), - KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) -) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; -CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', - sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', - product_quantity BIGINT 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), - UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code), KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code) -) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; -CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', - carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', - before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', - business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', - occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) -) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; - -CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( - id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', - source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', - customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', - source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', - business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', - order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', - inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', - container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', - warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', - fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', - supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', - 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', - 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', - created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), - 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_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')), - CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) -) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; - -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); -PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; - -CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( - id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', - action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', - source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), - KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) -) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; - -CREATE TABLE IF NOT EXISTS ll_exception_order ( - id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号', - exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因', - last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码', - delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号', - customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', - sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号', - handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果', - evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID', - handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', - source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID', - exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间', - version BIGINT NOT NULL DEFAULT 0 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), - UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), - KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code) -) ENGINE=InnoDB COMMENT='海外仓异常订单'; -CREATE TABLE IF NOT EXISTS ll_exception_return_item ( - id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', - exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', - inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', - sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', - approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), - KEY idx_ll_exception_return_order(customer_id,exception_order_id) -) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; -CREATE TABLE IF NOT EXISTS ll_exception_order_command ( - id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', - exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', - idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希', - result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key), - KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) -) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; -CREATE TABLE IF NOT EXISTS ll_customer_visit ( - id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', - delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', - contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', - delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', - selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', - selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', - visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', - attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', - visit_count INT NOT NULL DEFAULT 0 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), - UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id), KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), - KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) -) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; - -- 收敛曾由历史局部脚本建立的五期中间结构;只补结构,不回填或修复旧数据。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_outbound_order RENAME COLUMN tenant_id TO customer_id')); PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 1f17c177c..3ff8d9f52 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -39,12 +39,8 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), - (5107230,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:carton:list','海外箱号','/overseas-warehouse/cartons','GET',930,'ACTIVE',1,0), - (5107231,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:create','创建海外箱号',NULL,'POST',931,'ACTIVE',1,0), - (5107232,0,'PLATFORM','BUTTON',5107230,'platform:overseas-warehouse:carton:edit','编辑海外箱号',NULL,'PUT',932,'ACTIVE',1,0), - (5107240,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:product:list','海外产品库','/overseas-warehouse/products','GET',940,'ACTIVE',1,0), - (5107241,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:create','创建海外产品',NULL,'POST',941,'ACTIVE',1,0), - (5107242,0,'PLATFORM','BUTTON',5107240,'platform:overseas-warehouse:product:edit','编辑海外产品',NULL,'PUT',942,'ACTIVE',1,0), + (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,0), + (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 073da84a4..29b1469dd 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -41,7 +41,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(56).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(58).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java index 586d7dde6..47af11c35 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -52,7 +52,7 @@ public interface PdaWarehouseMapper extends BaseMapper { "FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " + "AND h.operation_type=#{operationType} " + "UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," + - "CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " + + "CONCAT('柜号作业 ',s.container_no),NULL customer_id,o.created_at " + "FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " + "WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " + "AND CONCAT('RECEIVING_',o.action)=#{operationType} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 5a4efd302..ce50bb094 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -15,6 +15,8 @@ public final class OverseasPermissions { public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; + public static final String RETURN_INVENTORY_LIST = "platform:overseas-warehouse:return-inventory:list"; + public static final String RETURN_CARTONS_LIST = "platform:overseas-warehouse:return-cartons:list"; public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list"; public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 4d39e0b4f..0b4c9ec47 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -952,7 +952,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) ) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; @@ -1356,7 +1356,7 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息'' AFTER order_no')); PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( @@ -2058,6 +2058,21 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; +-- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); +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_security_projection' AND column_name='authorities_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; +UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; +ALTER TABLE ll_security_projection + MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; + -- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); SET @platform_admin_overseas_authorities = JSON_ARRAY( @@ -2121,9 +2136,6 @@ SET @platform_admin_overseas_authorities = JSON_ARRAY( 'platform:overseas-master-data:vehicle:export', 'platform:overseas-master-data:vehicle:list', 'platform:overseas-master-data:vehicle:status', - 'platform:overseas-warehouse:carton:create', - 'platform:overseas-warehouse:carton:edit', - 'platform:overseas-warehouse:carton:list', 'platform:overseas-warehouse:customer-visit:edit', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:delivery:documents', @@ -2160,12 +2172,11 @@ SET @platform_admin_overseas_authorities = JSON_ARRAY( 'platform:overseas-warehouse:pallet:delete', 'platform:overseas-warehouse:pallet:edit', 'platform:overseas-warehouse:pallet:list', - 'platform:overseas-warehouse:product:create', - 'platform:overseas-warehouse:product:edit', - 'platform:overseas-warehouse:product:list', 'platform:overseas-warehouse:relabel-order:create', 'platform:overseas-warehouse:relabel-order:list', 'platform:overseas-warehouse:relabel-order:status', + 'platform:overseas-warehouse:return-cartons:list', + 'platform:overseas-warehouse:return-inventory:list', 'platform:overseas-warehouse:warehouse:create', 'platform:overseas-warehouse:warehouse:edit', 'platform:overseas-warehouse:warehouse:list', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 9dca12033..1c69473d7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -64,7 +64,7 @@ class DeliverySchemaContractTest { String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); - assertTrue(overseas.contains("空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线")); + assertTrue(overseas.contains("begin inline empty-database baseline: lastleg")); assertTrue(overseas.contains(createToken)); assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable")); assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java index 7d999d81c..a31c6267b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -60,6 +60,14 @@ class PdaWarehouseContractTest { .contains("unique key uk_ll_pda_history_idempotency(operator_key,idempotency_key)"); } + @Test + void sharedContainerHistoryDoesNotReadRemovedCustomerScope() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java")); + assertThat(mapper).contains("NULL customer_id") + .doesNotContain("s.customer_id"); + } + @Test void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception { String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml")); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index e5b216507..a92d0ef0b 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1582,7 +1582,9 @@ export class AcceptanceRun { if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) } - if (Number(readiness().distinctContainerCount || 0) < 4) { + const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) + .some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:')) + if (!hasSecondaryProducer()) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } const matrix = this.createdResources.phase4InboundProducerFixtures diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index a5a501293..dd3633d11 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -92,18 +92,26 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影 )?.[1] assert.ok(authorityBlock, 'missing platform_admin overseas authority snapshot') const authorities = [...authorityBlock.matchAll(/'([^']+)'/g)].map((match) => match[1]) - assert.equal(authorities.length, 109) + const deferred = new Set([ + 'platform:overseas-warehouse:carton:list', + 'platform:overseas-warehouse:carton:create', + 'platform:overseas-warehouse:carton:edit', + 'platform:overseas-warehouse:product:list', + 'platform:overseas-warehouse:product:create', + 'platform:overseas-warehouse:product:edit', + ]) + assert.equal(authorities.length, 105) assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates') assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34) assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11) assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15) - assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 49) + assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 45) for (const authority of authorities) { assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`) } for (const source of [overseasWarehousePermissions, overseasBusinessPermissions, overseasMasterDataPermissions]) { const declared = [...source.matchAll(/'((?:platform:overseas-warehouse|platform:overseas-business|platform:overseas-master-data):[^']+)'/g)] - .map((match) => match[1]) + .map((match) => match[1]).filter((authority) => !deferred.has(authority)) for (const authority of declared) { assert.ok( [lastlegPermissionSeed, phase5DeliveryPermissionSeed].some((seed) => seed.includes(`'${authority}'`)), @@ -112,6 +120,10 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影 assert.ok(authorities.includes(authority), `Admin permission is absent from the fixed-admin overseas snapshot: ${authority}`) } } + for (const authority of deferred) { + assert.ok(!lastlegPermissionSeed.includes(`'${authority}'`), `deferred permission entered the IAM source seed: ${authority}`) + assert.ok(!authorities.includes(authority), `deferred permission entered the fixed-admin snapshot: ${authority}`) + } for (const required of [ 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 83d107f6c..74b0a5af0 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -120,6 +120,9 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(entry, /const hasSecondaryProducer =/) + assert.match(entry, /if \(!hasSecondaryProducer\(\)\)/) + assert.doesNotMatch(entry, /if \(Number\(readiness\(\)\.distinctContainerCount \|\| 0\) < 4\)/) const start = entry.indexOf('if (pickupRequired && producerKey)') const end = entry.indexOf('} finally {', start) const pickupChain = entry.slice(start, end) From 630150a7c749b7ae74afca297580700c12c8a7a1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 10 Sep 2026 10:34:24 +0800 Subject: [PATCH 42/42] fix: allow unassigned overseas receiving warehouse --- deploy/sql/海外区域统一增量-MySQL8.sql | 12 ++++++------ lastleg-service/src/main/resources/db/lastleg.sql | 12 ++++++------ .../pda/ReceivingContainerContractTest.java | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a899a53a6..ee4c98be0 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -150,9 +150,9 @@ CREATE TABLE IF NOT EXISTS ll_order ( customer_name VARCHAR(255) COMMENT '货主名称', source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照', source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照', - overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', - overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', - overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', @@ -530,9 +530,9 @@ ALTER TABLE ll_order MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', - MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', - MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', - MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0b4c9ec47..e79d349d2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -58,9 +58,9 @@ CREATE TABLE IF NOT EXISTS ll_order ( customer_name VARCHAR(255) COMMENT '货主名称', source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照', source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照', - overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', - overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', - overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', @@ -438,9 +438,9 @@ ALTER TABLE ll_order MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', - MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓', - MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', - MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index e09083691..5b0ff3ef4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -103,6 +103,10 @@ class ReceivingContainerContractTest { assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); + String order = tableBlock(schema, "ll_order"); + assertTrue(order.contains("overseas_warehouse_id varchar(128) null")); + assertTrue(order.contains("overseas_warehouse_code varchar(128) null")); + assertTrue(order.contains("overseas_warehouse_name varchar(255) null")); String receivingSession = tableBlock(schema, "ll_receiving_container_session"); assertTrue(receivingSession.contains("sign_photos json")); assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null"));