From 6771eba2d4262a23393901477b4070cd9c8340d6 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 18:16:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 +- .../V8__chargeable_weight_confirmation.sql | 15 ----- ...9__drop_chargeable_weight_confirmation.sql | 4 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 16 +---- .../mapper/MerchantWorkbenchFilterMapper.java | 1 + .../MerchantWorkbenchFilterRepository.java | 37 +++++++++++ .../MerchantWorkbenchFilterService.java | 16 ++--- .../workflow/ChargeableWeightService.java | 57 ----------------- .../MerchantChargeableWeightController.java | 26 -------- .../firstleg/workflow/WorkflowFactMapper.java | 40 ++++++------ .../workflow/WorkflowFactRepository.java | 16 ++--- .../firstleg/workflow/WorkflowFacts.java | 13 ++-- .../firstleg/workflow/WorkflowTabService.java | 8 ++- .../db/chargeable-weight-confirmation.sql | 13 ---- .../main/resources/openapi/firstleg-v1.yaml | 4 +- .../resources/openapi/order-workflow-v1.yaml | 64 ------------------- .../MerchantWorkbenchFilterMapperTest.java | 3 +- .../MerchantWorkbenchFilterServiceTest.java | 21 +++--- .../workflow/WorkflowBoundaryTest.java | 29 +-------- .../workflow/WorkflowBusinessTest.java | 34 ++++------ .../workflow/WorkflowFactPersistenceTest.java | 61 +++++++++++------- scripts/agents-index-contract.ps1 | 4 +- 22 files changed, 152 insertions(+), 332 deletions(-) delete mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java delete mode 100644 firstleg/src/main/resources/db/chargeable-weight-confirmation.sql delete mode 100644 firstleg/src/main/resources/openapi/order-workflow-v1.yaml diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 784182525..1a7498180 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -4,7 +4,7 @@ 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 -客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 +客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql deleted file mode 100644 index 481e45442..000000000 --- a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 --- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。 --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql new file mode 100644 index 000000000..cf83e363d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql @@ -0,0 +1,4 @@ +-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。 +-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。 +-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。 +DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index db18e9fdd..8f6843980 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4437,20 +4437,6 @@ CREATE TABLE fl_inbound_split_item ( SET SESSION foreign_key_checks = 1; --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; - -- 服务 schema:baiyue_file CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_file; @@ -8391,4 +8377,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index 12b1223ea..dcec5ed35 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -23,6 +23,7 @@ public interface MerchantWorkbenchFilterMapper { FROM fl_inbound_order i JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL + AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED' AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java new file mode 100644 index 000000000..7da7c0d08 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 客户工作台筛选事实的持久化入口。 */ +@Repository +public class MerchantWorkbenchFilterRepository { + private final MerchantWorkbenchFilterMapper mapper; + + /** 注入客户隔离的筛选查询。 */ + public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) { + this.mapper = mapper; + } + + /** 查询物流主状态对应的订单范围。 */ + public List byProcessStatuses(long tenantId, List statuses) { + return mapper.byProcessStatuses(tenantId, statuses); + } + + /** 只返回最新已推送费用仍待客户确认的订单。 */ + public List pendingFees(long tenantId) { + return mapper.pendingFees(tenantId); + } + + /** 查询待客户补齐报关资料的订单。 */ + public List pendingCustoms(long tenantId) { + return mapper.pendingCustoms(tenantId); + } + + /** 查询客户订单的实际履约片段。 */ + public List progressFragments(long tenantId, List orderIds) { + return mapper.progressFragments(tenantId, orderIds); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java index 3418b78c4..e00315275 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -1,30 +1,30 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; -import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @Service public class MerchantWorkbenchFilterService { - @Resource private MerchantWorkbenchFilterMapper mapper; + @Resource private MerchantWorkbenchFilterRepository repository; @Resource private UnifiedInboundQueryService unified; - @Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights; - /** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */ + /** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */ @org.springframework.transaction.annotation.Transactional(readOnly=true) public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { if (request.orderIds() != null) { return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(), - progress(mapper.progressFragments(request.tenantId(), request.orderIds()))); + progress(repository.progressFragments(request.tenantId(), request.orderIds()))); } return new MerchantWorkbenchFilters.Response( - request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), + request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(), unified.internalStatuses(request.mainStatus())), - weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()), - progress(mapper.progressFragments(request.tenantId(), null))); + repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()), + progress(repository.progressFragments(request.tenantId(), null))); } + /** 将实装片段聚合为订单级履约进度。 */ static java.util.List progress(java.util.List facts) { var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId)); return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java deleted file mode 100644 index a2274c93b..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.baiyue.firstleg.workflow; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import java.util.*; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.time.LocalDateTime; -/** 客户独立确认计费重,金额确认和主流程不受影响。 */ -@Service -public class ChargeableWeightService { - private final WorkflowFactRepository repository; - /** 注入当前事实和确认历史。 */ - public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; } - /** 客户仅能读取本订单已审核的计费重。 */ - public List read(long tenant,long orderId) { - return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList(); - } - /** 客户工作台按独立确认事实计算待办,不借用费用确认。 */ - public List pendingOrders(long tenant) { - var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet(); - for(int offset=0;offset0; } - /** 只认可当前版本的确认。 */ - public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) { - String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint()); - return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null); - } - /** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */ - @Transactional - public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) { - if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符"); - var inbound=repository.lock(tenant,id); - if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在"); - var replay=repository.command(tenant,operator,key); - if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重"); - var facts=repository.facts(List.of(id)); - if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重"); - var f=facts.get(0); - if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过"); - var current=weight(f); - if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认"); - if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now())); - return weight(repository.facts(List.of(id)).get(0)); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java deleted file mode 100644 index 7be5a3db9..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.firstleg.workflow; -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.service.FirstlegScopeGuard; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import jakarta.validation.Valid; -import java.util.List; -/** 客户计费重确认入口,独立于费用确认。 */ -@RestController -@RequestMapping("/api/merchant/v1/first-leg") -public class MerchantChargeableWeightController { - private final ChargeableWeightService service; - /** 注入独立确认服务。 */ - public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; } - /** 客户范围取认证上下文。 */ - @GetMapping("/orders/{orderId}/chargeable-weights") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ApiResponse> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); } - /** 不触发费用确认与金额锁定。 */ - @com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重") - @PostMapping("/chargeable-weights/{inboundId}/confirm") - @PreAuthorize("hasAuthority('merchant:order:edit')") - public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java index dce644169..3459fd8c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.workflow; import org.apache.ibatis.annotations.*; import java.util.List; -/** 柜事实和独立确认SQL入口。 */ +/** 柜事实与最新费用确认状态的只读 SQL 入口。 */ @Mapper public interface WorkflowFactMapper { String ACTUAL=""" @@ -13,29 +13,29 @@ public interface WorkflowFactMapper { AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0 """; /** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */ - @Select("") List facts(@Param("ids") List ids); - /** 锁定客户自己的入仓单,串行确认与测量写入。 */ - @Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE") - com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id); - /** 客户订单下全部独立入仓单。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id") - List orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); - /** 客户工作台只读取审核通过且有正计费重的来源。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id") - List weightCandidates(@Param("tenantId") long tenantId); - /** 成员幂等键不允许跨确认对象复用。 */ - @Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}") - WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key); - /** 独立记录,不修改费用或物流主状态。 */ - @Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})") - int insert(WorkflowFacts.Confirmation value); + /** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */ + @Select(""" + + """) + List feeConfirmationRows(@Param("ids") List ids); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java index 567aaba6a..6d1bd9ba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.workflow; import org.springframework.stereotype.Repository; import java.util.List; -/** 本地柜事实与确认记录的持久化入口。 */ +/** 本地柜事实与费用确认状态的只读持久化入口。 */ @Repository public class WorkflowFactRepository { private final WorkflowFactMapper mapper; @@ -9,14 +9,8 @@ public class WorkflowFactRepository { public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; } /** 空候选不读取数据。 */ public List facts(List ids) { return ids.isEmpty()?List.of():mapper.facts(ids); } - /** 锁定所属客户来源。 */ - public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); } - /** 查询订单下独立来源。 */ - public List orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); } - /** 客户计费重待确认候选。 */ - public List weightCandidates(long tenant) { return mapper.weightCandidates(tenant); } - /** 读取成员确认命令。 */ - public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); } - /** 追加确认历史。 */ - public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); } + /** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */ + public List feeConfirmationRows(List ids) { + return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java index afa08a995..173f66927 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -1,17 +1,12 @@ package com.baiyue.firstleg.workflow; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; -/** 页签事实与独立计费重确认契约。 */ +/** 订单业务页签的只读事实契约。 */ public final class WorkflowFacts { - /** 仅承载查询及确认契约,不允许实例化。 */ + /** 仅承载事实查询契约,不允许实例化。 */ private WorkflowFacts() {} - public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt, - boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {} + public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus, + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {} public record Source(long tenantId,long inboundId) {} public record Request(List sources) {} public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} - public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {} - public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {} - public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index c513cbf9f..522242750 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -22,6 +22,11 @@ public class WorkflowTabService { var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size())); var expected=new HashMap();batch.forEach(i->expected.put(i.getId(),i)); var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); + // 确认页签复用已推送费用版本。 + for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { + if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); + result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); + } var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { @@ -46,11 +51,10 @@ public class WorkflowTabService { } return List.copyOf(result.values()); } - /** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */ + /** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */ public static Set classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); - if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM"); boolean signed="DELIVERED".equals(process); // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 if(!f.departed() && !signed) { diff --git a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql deleted file mode 100644 index 1dcc7e9c7..000000000 --- a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6e2607eea..59219443d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 + description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -388,7 +388,7 @@ paths: required: [feeOrderIds, customsOrderIds, progress] properties: matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} - feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}} + feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}} customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array diff --git a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml deleted file mode 100644 index 9c9abf1e4..000000000 --- a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml +++ /dev/null @@ -1,64 +0,0 @@ -openapi: 3.0.3 -info: {title: 客户独立计费重确认, version: 1.0.0} -security: [{bearerAuth: []}] -paths: - /api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights: - get: - summary: 客户读取本票全部独立入仓计费重 - description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。 - parameters: - - {name: orderId, in: path, required: true, schema: {type: integer, format: int64}} - responses: - '200': - description: 标准ApiResponse,认证客户无可确认来源时返回空数组 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: - type: array - items: {$ref: '#/components/schemas/Weight'} - /api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm: - post: - summary: 客户独立确认当前计费重 - description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [expectedFingerprint] - properties: - expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'} - responses: - '200': - description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: {$ref: '#/components/schemas/Weight'} -components: - securitySchemes: - bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} - schemas: - Weight: - type: object - required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed] - properties: - inboundId: {type: string, description: 独立来源入仓单ID} - orderId: {type: string, description: 独立订单ID} - chargeableWeightKg: {type: number} - fingerprint: {type: string} - confirmed: {type: boolean} - confirmedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index 00b029a6d..ab435876e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -19,6 +19,7 @@ class MerchantWorkbenchFilterMapperTest { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); jdbc=new JdbcTemplate(ds); jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'"); jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)"); @@ -33,7 +34,7 @@ class MerchantWorkbenchFilterMapperTest { var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class); - jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')"); jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java index f9d21c3b5..f8a0117a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; -import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -9,25 +9,22 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class MerchantWorkbenchFilterServiceTest { - /** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */ - @Test void customerQueueUsesIndependentWeightConfirmation() { - var mapper=mock(MerchantWorkbenchFilterMapper.class); - var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class); + /** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */ + @Test void customerQueueUsesPublishedFeeConfirmation() { + var repository=mock(MerchantWorkbenchFilterRepository.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); - ReflectionTestUtils.setField(service,"weights",weights); - when(weights.pendingOrders(11)).thenReturn(List.of(101L)); + ReflectionTestUtils.setField(service,"repository",repository); + when(repository.pendingFees(11)).thenReturn(List.of(101L)); var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null)); assertThat(response.feeOrderIds()).containsExactly(101L); - verify(weights).pendingOrders(11); - verify(mapper,never()).pendingFees(anyLong()); + verify(repository).pendingFees(11); } @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() { - var mapper=mock(MerchantWorkbenchFilterMapper.class); + var mapper=mock(MerchantWorkbenchFilterRepository.class); var unified=mock(UnifiedInboundQueryService.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"repository",mapper); ReflectionTestUtils.setField(service,"unified",unified); when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of( new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index 409acb688..d8db7cf4d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -4,8 +4,6 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundOrderEntity; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -15,8 +13,7 @@ import static org.mockito.Mockito.*; class WorkflowBoundaryTest { /** 真实开船来源的测试事实。 */ private WorkflowFacts.Fact fact() { - return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN, - LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null); + return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false); } /** 下游漏来源或返回其他客户均整体失败。 */ @@ -36,28 +33,4 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); } - /** 重测同值也要求重新确认,金额没有参与摘要。 */ - @Test void repeatedMeasurementRequiresNewConfirmation() { - var original=fact(); - var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN, - original.measureCompletedAt().plusSeconds(1),true,false,false,false, - ChargeableWeightService.fingerprint(original),LocalDateTime.now()); - assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse(); - } - - /** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */ - @Test void confirmationUsesAuthenticatedCustomerAndOperator() { - var service=mock(ChargeableWeightService.class); - var controller=new MerchantChargeableWeightController(service); - var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of()); - var request=new WorkflowFacts.Confirm("a".repeat(64)); - assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class); - assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class); - verifyNoInteractions(service); - var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of()); - controller.read(merchant,101); - controller.confirm(merchant,1,"key",request); - verify(service).read(11,101); - verify(service).confirm(11,7,1,"key",request); - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index 6772b8ab7..4106e0665 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -1,18 +1,15 @@ package com.baiyue.firstleg.workflow; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; -import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.*; -/** 取消回显、分柜隔离和独立计费重版本回归。 */ +/** 取消回显、分柜隔离和资料页签回归。 */ class WorkflowBusinessTest { /** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */ @Test void customsDocumentsDepartureAndClearanceDriveTabs() { - var time=LocalDateTime.of(2026,10,19,10,0); - var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null); - var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null); - var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null); - var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null); - var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null); + var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false); + var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true); + var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true); + var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true); + var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true); var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false); var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true); assertThat(WorkflowTabService.classify(missing,"LOADED",null)) @@ -31,12 +28,12 @@ class WorkflowBusinessTest { .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); } /** 构造独立分柜来源。 */ - private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) { - return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null); + private WorkflowFacts.Fact fact(long id,boolean uploaded) { + return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false); } /** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */ @Test void cancellationRestoresDispatchQueue() { - var f=fact(1,"100",null,true); + var f=fact(1,true); var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true); var created=new WorkflowFacts.Overseas(11,1,true,true,false,true); var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true); @@ -47,17 +44,10 @@ class WorkflowBusinessTest { } /** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */ @Test void independentSlicesAndUploadedClearance() { - assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) .contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD"); - assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) + assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) .contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH"); - assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); - } - /** 数值格式不改变版本,值或来源变化必须重新确认。 */ - @Test void independentWeightVersion() { - var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false)); - assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue(); - assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse(); - assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse(); + assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java index 75d5c5565..23b012520 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.mybatis.spring.SqlSessionTemplate; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import java.nio.file.*; import java.util.List; import static org.assertj.core.api.Assertions.*; -/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */ +/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */ class WorkflowFactPersistenceTest { /** 测试建表不连接业务数据库。 */ - @Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception { + @Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); var db=new JdbcTemplate(ds); db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))"); + db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)"); db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)"); db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))"); @@ -29,7 +29,6 @@ class WorkflowFactPersistenceTest { db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT"); db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)"); db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)"); - db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql"))); db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')"); db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)"); db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)"); @@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest { db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class); config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class); + config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class); var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class); + var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class); assertThat(containers.hasActualVesselDeparture(5)).isOne(); assertThat(containers.hasActualVesselDeparture(6)).isZero(); db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)"); assertThat(containers.hasActualVesselDeparture(6)).isZero(); - var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository); - assertThat(service.read(22,101)).isEmpty(); + var repository=new WorkflowFactRepository(mapper); assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();}); db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)"); assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue(); @@ -92,29 +92,42 @@ class WorkflowFactPersistenceTest { assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{ assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue(); }); - var current=service.read(11,101).get(0); - assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue(); - assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue(); - assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1); - assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1"); - assertThat(service.read(11,101).get(0).confirmed()).isFalse(); - assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + // 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。 + var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class); + org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + var tabs=new WorkflowTabService(repository,downstream); + var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity(); + inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + // 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。 + db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")) + .noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + assertThat(workbench.pendingFees(22)).containsExactly(102L); + db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1"); + assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty(); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1"); // 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。 db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')"); assertThat(mapper.facts(List.of(1L,3L))).hasSize(2); assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue(); assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse(); - assertThat(service.read(11,101)).hasSize(1); - assertThat(service.read(11,103)).hasSize(1); - // 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。 + // 父单仅作汇总时,不重复进入业务页签。 db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3"); - assertThat(service.read(11,101)).isEmpty(); - assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - var child=service.read(11,103).get(0); - assertThat(child.confirmed()).isFalse(); - assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue(); + assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3)); } } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index dafc6ce68..bdb2b14a1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -562,8 +562,8 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' -Assert-Contains $clientAgents '所有订单行均可进入计费重弹框' 'Client 独立计费重入口' -Assert-Contains $clientOrdersView 'dialog-only' 'Client 独立计费重弹框实现' +Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口' +Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现' Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' # 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。 From 34b90f311ab5ecd7831113a3c6aeb32b6a1d1e6b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 18:44:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index bdb2b14a1..f5b14cba4 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -562,7 +562,7 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' -Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口' +Assert-Contains $clientAgents 'customerFeeConfirmationRequired=true' 'Client 费用待确认入口' Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现' Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' From 137efc6809809d7e41820c83829660c6bbedd328 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 21:47:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + .../V32__dispatch_transport_tasks.sql | 23 +++ .../V33__looseboard_pool_demand.sql | 13 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 43 +++- .../mapper/MerchantWorkbenchFilterMapper.java | 8 +- .../firstleg/workflow/WorkflowFactMapper.java | 3 +- .../firstleg/workflow/WorkflowFacts.java | 2 +- .../firstleg/workflow/WorkflowTabService.java | 14 +- .../main/resources/openapi/firstleg-v1.yaml | 4 +- .../MerchantWorkbenchFilterMapperTest.java | 7 + .../workflow/WorkflowBoundaryTest.java | 21 +- .../workflow/WorkflowBusinessTest.java | 37 ++-- .../workflow/WorkflowFactPersistenceTest.java | 3 +- .../analyze-lastleg-dependencies.test.mjs | 13 +- .../dispatch/entity/DispatchAggregate.java | 1 + .../mapper/DispatchAggregateMapper.java | 10 +- .../lastleg/dispatch/port/DispatchViews.java | 4 +- .../service/DispatchAggregateService.java | 10 +- .../transport/TransportTaskController.java | 91 +++++++++ .../transport/TransportTaskMapper.java | 42 ++++ .../transport/TransportTaskModels.java | 23 +++ .../transport/TransportTaskProjection.java | 84 ++++++++ .../transport/TransportTaskRepository.java | 34 ++++ .../transport/TransportTaskService.java | 158 +++++++++++++++ .../business/FleetLooseboardDemandMapper.java | 29 +++ .../FleetLooseboardDemandRepository.java | 28 +++ .../FleetLooseboardDemandService.java | 52 +++++ .../fleet/business/FleetPoolOrderBridge.java | 14 +- .../fleet/business/FleetPoolOrderMapper.java | 4 +- .../fleet/trip/FleetCapacityOfferModels.java | 11 +- .../fleet/trip/FleetCapacityOfferService.java | 12 +- .../fleet/trip/FleetTripExecutionService.java | 2 +- .../lastleg/fleet/trip/FleetTripService.java | 12 +- .../PlatformFleetCapacityOfferController.java | 3 + .../service/LocalLooseboardService.java | 9 +- .../trucking/mapper/UsTruckOrderMapper.java | 3 + .../repository/UsTruckOrderRepository.java | 2 + .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/db/lastleg.sql | 28 +++ .../openapi/dispatch-transport-tasks-v1.yaml | 99 ++++++++++ .../openapi/fleet-capacity-offers-v1.yaml | 60 ++++++ .../DispatchAggregatePersistenceTest.java | 19 ++ .../TransportTaskProjectionTest.java | 31 +++ .../transport/TransportTaskServiceTest.java | 63 ++++++ .../FleetLooseboardDemandPersistenceTest.java | 50 +++++ .../FleetLooseboardDemandServiceTest.java | 39 ++++ .../business/FleetPoolOrderBridgeTest.java | 19 ++ .../fleet/trip/FleetTripApiContractTest.java | 2 +- .../trip/FleetTripAuthorizationTest.java | 16 ++ .../service/LocalLooseboardServiceTest.java | 13 +- .../lastleg-slice-dependencies.json | 35 ++++ .../src/main/resources/openapi/order-v1.yaml | 2 +- scripts/README.md | 2 + .../local-dispatch-transport-acceptance.mjs | 186 ++++++++++++++++++ 54 files changed, 1434 insertions(+), 63 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java create mode 100644 scripts/local-dispatch-transport-acceptance.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1a7498180..37b406507 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。 + 排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql new file mode 100644 index 000000000..4bbd4a3e3 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql @@ -0,0 +1,23 @@ +-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。 +ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no; +CREATE TABLE ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql new file mode 100644 index 000000000..d947295bf --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql @@ -0,0 +1,13 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。 +-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。 +-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。 +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)'); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a82791968..8d24dba5f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -416,6 +416,7 @@ CREATE TABLE ll_dispatch_item ( CREATE TABLE ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', @@ -3222,6 +3223,7 @@ CREATE TABLE ll_fleet_external_booking ( binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_hash CHAR(64) NULL COMMENT '关联发运摘要', offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源', sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', @@ -3236,6 +3238,7 @@ CREATE TABLE ll_fleet_external_booking ( KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status), PRIMARY KEY(id), UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), @@ -4968,4 +4971,42 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; +-- 运输原单接入现有排车,不产生仓内库存事实。 +USE baiyue_lastleg; +CREATE TABLE ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; + +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。 +-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。 +-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。 +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)'); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index dcec5ed35..34b48058b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -64,13 +64,13 @@ public interface MerchantWorkbenchFilterMapper { ORDER BY i.order_id """) List pendingCustoms(@Param("tenantId") long tenantId); - /** Only physically loaded fragments can expose container milestones to the customer. */ + /** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */ @Select(""" ") List facts(@Param("ids") List ids); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java index 173f66927..97663a728 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -5,7 +5,7 @@ public final class WorkflowFacts { /** 仅承载事实查询契约,不允许实例化。 */ private WorkflowFacts() {} public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus, - boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {} + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {} public record Source(long tenantId,long inboundId) {} public record Request(List sources) {} public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 522242750..418650e34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -27,7 +27,7 @@ public class WorkflowTabService { if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } - var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { try { @@ -56,16 +56,18 @@ public class WorkflowTabService { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); boolean signed="DELIVERED".equals(process); - // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 - if(!f.departed() && !signed) { + // 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。 + boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded(); + if(!clearanceStage && !signed) { if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS"); if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS"); } if(signed) keys.add("SIGNED"); - if(f.departed() && !signed) { + if(clearanceStage && !signed) { if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE"); - if(o!=null) { - if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD"); + // 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。 + if(f.clearanceUploaded() && o!=null) { + if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD"); // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 59219443d..472378efb 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -392,7 +392,7 @@ paths: customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array - description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。 + description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。 items: type: object required: [orderId, stage, partial] @@ -1674,7 +1674,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] nullable: true - description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。 + description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 customerId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index ab435876e..fc10ee0f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -30,6 +30,7 @@ class MerchantWorkbenchFilterMapperTest { jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY"); jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(32))"); jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(32),picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)"); jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(64),actual_at TIMESTAMP)"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); @@ -95,6 +96,12 @@ class MerchantWorkbenchFilterMapperTest { jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)"); assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP"); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); + // 其他柜、其他资料类型或未绑定文件不能视为清关资料提交成功。 + jdbc.update("INSERT INTO fl_container_document VALUES(999,'DESTINATION_CLEARANCE',1),(100,'DELIVERY_ORDER',2),(100,'DESTINATION_CLEARANCE',NULL)"); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); + jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL"); + jdbc.update("UPDATE fl_container_document SET file_id=3 WHERE document_type='DESTINATION_CLEARANCE' AND container_booking_id=100"); assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3); assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);}); jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index d8db7cf4d..cf264bcf4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -13,7 +13,7 @@ import static org.mockito.Mockito.*; class WorkflowBoundaryTest { /** 真实开船来源的测试事实。 */ private WorkflowFacts.Fact fact() { - return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false); + return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false,false); } /** 下游漏来源或返回其他客户均整体失败。 */ @@ -33,4 +33,23 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); } + /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ + @Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() { + var repository=mock(WorkflowFactRepository.class); + var downstream=mock(LastlegWorkflowClient.class); + var service=new WorkflowTabService(repository,downstream); + var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L); + inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE"); + verifyNoInteractions(downstream); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); + when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( + new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); + verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index 4106e0665..e0aa37b6e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -3,33 +3,46 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; /** 取消回显、分柜隔离和资料页签回归。 */ class WorkflowBusinessTest { - /** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */ + /** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */ @Test void customsDocumentsDepartureAndClearanceDriveTabs() { - var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false); - var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true); - var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true); - var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true); - var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true); + var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false); + var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false); + var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,false); + var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,false); + var completed=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true); + var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,false); var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false); var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true); assertThat(WorkflowTabService.classify(missing,"LOADED",null)) .contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD"); assertThat(WorkflowTabService.classify(uploaded,"LOADED",null)) .contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD"); + assertThat(WorkflowTabService.classify(completed,"LOADED",null)) + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload)) - .contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD"); + .containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload)) - .contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD"); + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload)) - .contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE"); + .containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded)) .doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS"); assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded)) .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); } + /** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */ + @Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() { + var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false); + var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT"); + assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING"); + assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED"); + } /** 构造独立分柜来源。 */ private WorkflowFacts.Fact fact(long id,boolean uploaded) { - return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false); + return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false,false); } /** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */ @Test void cancellationRestoresDispatchQueue() { @@ -45,9 +58,9 @@ class WorkflowBusinessTest { /** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */ @Test void independentSlicesAndUploadedClearance() { assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) - .contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD"); + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) - .contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH"); + .containsExactly("OUTBOUND_DOCUMENT"); assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java index 23b012520..2fa9ef78e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -60,10 +60,11 @@ class WorkflowFactPersistenceTest { db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)"); db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)"); assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();}); - // 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。 + // 本票资料上传后进入出口报关汇总,报关完成事实独立驱动待清关。 for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) { db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status); assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue(); + assertThat(mapper.facts(List.of(1L)).get(0).customsCompleted()).isEqualTo("COMPLETED".equals(status)); } db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10"); assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{ diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index aa50f7c47..77fa9d41e 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -49,7 +49,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain'); assert.equal(policyClasses.length, 3); for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.'))); - assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']); + const reviewed = JSON.parse(readFileSync(contract, 'utf8')).reviewedClassDependencies; + const commercialEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && /com\.baiyue\.lastleg\.(trucking|looseboard|procurement)\./.test(edge.target)); + assert.ok(commercialEdges.every(edge => reviewed.some(allowed => allowed.source === edge.source && allowed.target === edge.target)), 'commercial integration only uses individually reviewed class references'); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', ...(commercialEdges.length ? ['procurement-trucking-looseboard'] : []), 'shared-technical']); for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) { assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters'); } @@ -67,7 +70,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' - && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'), + && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' + || [ + 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels', + 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', + 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', + 'TransportTaskService->reporting.TripOperationsEvidenceGateway', + ].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(incomingEdges.length > 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java index 0fc306e52..c5975cb72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -8,6 +8,7 @@ import java.time.LocalDateTime; public class DispatchAggregate { private Long id; private String dispatchNo; + private String taskType; private Long tripId; private String overseasWarehouseId; private String overseasWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index d4c0197ef..06c73e8b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -14,7 +14,11 @@ public interface DispatchAggregateMapper { AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%') AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%') AND d.overseas_warehouse_id=#{q.warehouseId} - AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + AND ( + EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId}) + OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId}) + ) AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo}) """; String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; @@ -33,12 +37,12 @@ public interface DispatchAggregateMapper { /** 首次创建整车所有权及初始版本,不写customer_id。 */ @Insert(""" - INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code, + INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code, overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, remark,created_by,created_at,updated_at) - VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 75f767fdd..264aa0a35 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -29,7 +29,7 @@ public final class DispatchViews { public record Page(List records, long total, long page, long size, long pages, Statistics statistics, Instant snapshotAt) { } - public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion, + public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, ReviewState reviewState, Reviews reviews, Resources resources, String warehouseId, String warehouseCode, String warehouseName, Instant plannedDepartureAt, Instant departedAt, Counts counts, @@ -56,7 +56,7 @@ public final class DispatchViews { String sourceBusinessType, String sourceBusinessNo) { } /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ - public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, + public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, ReviewState reviewState, Reviews reviews, Counts counts, boolean pickingReconciled, Map actions, VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 7c5c760be..b2e83b1bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -647,7 +647,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.status(),d.packageCount(),d.palletCount())).toList(); var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); - return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), documents,orderDetails(cargo)); @@ -754,6 +754,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ Map actions(Actor actor,DispatchAggregate row) { + // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 + if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { + var blocked=new EnumMap(Action.class); + for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); + return Collections.unmodifiableMap(blocked); + } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); for(var action:Action.values()) { @@ -807,7 +813,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 列表不逐行读取明细、附件或派送。 */ private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) { - return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java new file mode 100644 index 000000000..59ac4f516 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +/** 两类原单通过现有排车权限接入,禁止客户主体借同名权限调用。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches/transport-tasks") +public class TransportTaskController { + private final TransportTaskService service; + private final FleetPickupService pickups; + private final FleetTripService trips; + private final FleetTripCompletionService completion; + /** 注入运输业务和现有现场执行能力。 */ + public TransportTaskController(TransportTaskService service,FleetPickupService pickups,FleetTripService trips,FleetTripCompletionService completion){this.service=service;this.pickups=pickups;this.trips=trips;this.completion=completion;} + /** 自动汇入审核后的上门取货需求。 */ + @GetMapping("/pickup-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse pickupCandidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query){operator(user);return ApiResponse.success(pickups.candidates(query));} + /** 自动汇入本土卡车待派车需求。 */ + @GetMapping("/truck-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse truckCandidates(@AuthenticationPrincipal AuthenticatedUser user){operator(user);return ApiResponse.success(service.truckCandidates());} + /** 创建入仓取货排车。 */ + @PostMapping("/pickup") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse pickup(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request){return ApiResponse.success(service.createPickup(operator(user),request));} + /** 创建本土卡车排车。 */ + @PostMapping("/truck") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse truck(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody TransportTaskModels.TruckCreate request){return ApiResponse.success(service.createTruck(operator(user),request));} + /** 当前排车详情的运输执行面板。 */ + @GetMapping("/{number}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));} + /** 分配或改派真实车辆司机。 */ + @PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));} + /** 取消未发车任务,来源回到待安排。 */ + @PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));} + /** 候选搜索复用统一资源占用真相。 */ + @PostMapping("/{number}/resource-occupancy") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse occupancy(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.ResourceQuery query){operator(user);return ApiResponse.success(trips.resourceOccupancy(service.transport(number,false).getTripNo(),query));} + /** 实际任务结束后分别释放车辆与司机。 */ + @PostMapping("/{number}/release") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetCompletionModels.Release request){return ApiResponse.success(completion.release(service.transport(number,false).getTripNo(),operator(user),request));} + /** 取货发车仅推进运输。 */ + @PostMapping("/{number}/pickup/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse departPickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.depart(service.transport(number,false).getTripNo(),operator(user),request));} + /** 全部送仓后结束取货运输。 */ + @PostMapping("/{number}/pickup/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.complete(service.transport(number,false).getTripNo(),operator(user),request));} + /** 凭证归属具体客户原单。 */ + @PostMapping(value="/{number}/pickup/{customer}/{source}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse pickupFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@RequestPart MultipartFile file){return ApiResponse.success(pickups.upload(service.transport(number,false).getTripNo(),customer,source,operator(user),file));} + /** 确认实提箱数和原单凭证。 */ + @PostMapping("/{number}/pickup/{customer}/{source}/picked") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Pickup request){return ApiResponse.success(pickups.picked(service.transport(number,false).getTripNo(),customer,source,operator(user),request));} + /** 送仓只开放原单收货门禁。 */ + @PostMapping("/{number}/pickup/{customer}/{source}/arrive") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.arrive(service.transport(number,false).getTripNo(),customer,source,operator(user),request));} + /** 取货凭证同时核对当前排车和原单归属。 */ + @GetMapping("/{number}/pickup/{customer}/{source}/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity pickupEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@PathVariable long fileId){ + operator(user);var trip=service.transport(number,false); + if(pickups.tasks(trip.getTripNo()).stream().noneMatch(task->task.customerId().equals(customer)&&task.transferNo().equals(source)))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前排车没有该原单"); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(pickups.evidence(customer,source,fileId)); + } + /** 本土卡车按真实运输发车。 */ + @PostMapping("/{number}/truck/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse departTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"depart",request));} + /** 本土卡车提供运输完成凭证后结案。 */ + @PostMapping("/{number}/truck/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse completeTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"complete",request));} + /** 本土卡车凭证绑定本次排车资源记录。 */ + @PostMapping(value="/{number}/truck/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse truckFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart MultipartFile file){return ApiResponse.success(service.upload(number,operator(user),file));} + /** 完成凭证经排车归属授权后下载。 */ + @GetMapping("/{number}/truck/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable long fileId){operator(user);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=transport-evidence").body(service.evidence(number,fileId));} + /** 认证身份来自网关安全上下文。 */ + private static String operator(AuthenticatedUser user){ + if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录"); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以操作排车");return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java new file mode 100644 index 000000000..b3fa3dc26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.apache.ibatis.annotations.*; + +/** 运输任务与现有排车主单的一对一关系及本土卡车原单关联。 */ +@Mapper +public interface TransportTaskMapper { + /** 已成功命令持久化的凭证引用可在刷新后恢复。 */ + @Select("SELECT evidence_json FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} ORDER BY created_at,request_key") + java.util.List evidence(String number); + /** 运输命令独立幂等账本,摘要包含动作与版本。 */ + @Select("SELECT request_hash FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} AND request_key=#{key}") + String commandHash(@Param("number") String number,@Param("key") String key); + /** 仅在执行成功同事务记账。 */ + @Insert("INSERT INTO ll_dispatch_transport_command(dispatch_no,request_key,request_hash,evidence_json) VALUES(#{number},#{key},#{hash},#{evidence})") + int command(@Param("number") String number,@Param("key") String key,@Param("hash") String hash,@Param("evidence") String evidence); + /** 创建键关联原始请求摘要,避免修改来源后重放。 */ + @Select("SELECT request_hash FROM ll_dispatch_transport_create WHERE trip_no=#{trip}") + String createHash(String trip); + /** 保存首次本土卡车安排内容。 */ + @Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})") + int createHashSave(@Param("trip") String trip,@Param("hash") String hash); + /** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */ + @Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100") + java.util.List candidates(); + /** 原取货创建请求重放时恢复同一排车号。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}") + DispatchAggregate byTrip(long trip); + /** 原单仅允许一个有效承运任务,取消后保留历史。 */ + @Insert("INSERT INTO ll_dispatch_truck_task(dispatch_no,trip_no,customer_id,order_no,active_order) VALUES(#{dispatchNo},#{tripNo},#{customerId},#{orderNo},CONCAT(#{customerId},':',#{orderNo}))") + int insert(TransportTaskModels.TruckLink row); + /** 按排车号读取本土卡车关联。 */ + @Select("SELECT dispatch_no,trip_no,customer_id,order_no FROM ll_dispatch_truck_task WHERE dispatch_no=#{number}") + TransportTaskModels.TruckLink truck(String number); + /** 取消任务解除有效单唯一约束,历史关联不删除。 */ + @Update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no=#{number}") + int cancel(String number); + /** 查询排车唯一资源记录。 */ + @Select("SELECT trip_no FROM ll_fleet_trip WHERE id=#{id}") + String tripNo(long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java new file mode 100644 index 000000000..ff74f2627 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.fleet.trip.FleetTripModels; +import com.baiyue.lastleg.fleet.pickup.FleetPickupModels; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +/** 排车列表中的到仓取货与本土卡车运输命令。 */ +public final class TransportTaskModels { + /** 仅承载契约。 */ + private TransportTaskModels() { } + public record TruckCreate(@NotBlank String customerId,@NotBlank String orderNo, + @NotNull @Positive Long warehouseId,@NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } + public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, + String customerId,String orderNo,List pickups, + List completionEvidence) { } + public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java new file mode 100644 index 000000000..831a9eb00 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.TransportTripChanged; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */ +@Service +public class TransportTaskProjection { + private final DispatchAggregateRepository roots; + private final TransportTaskRepository links; + private final UsTruckOrderRepository trucks; + private final DispatchNumberGenerator numbers; + private final ObjectMapper json; + /** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */ + public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) { + this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json; + } + /** 注册真实排车号;来源行锁和唯一 trip_id 防止重复建单。 */ + public DispatchAggregate register(FleetTripEntity trip,int items,int pallets,int packages,String operator) { + var existing=links.byTrip(trip.getId());if(existing!=null)return existing; + var row=new DispatchAggregate();row.setId(IdWorker.getId());row.setDispatchNo(numbers.next());row.setTaskType(trip.getTripPurpose()); + row.setTripId(trip.getId());row.setOverseasWarehouseId(trip.getWarehouseId().toString()); + row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName()); + row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L); + row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L); + var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null); + row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); + resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); + row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); + row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator); + row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row); + roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row; + } + /** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */ + @EventListener + public void changed(TransportTripChanged event) { + var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return; + var found=links.byTrip(trip.getId());if(found==null)return; + var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion(); + row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt()); + row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE"); + row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";}); + row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); + row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); + if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败"); + roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt()); + var link=links.truck(row.getDispatchNo());if(link==null)return; + String target=switch(trip.getStatus()){case "CANCELLED"->"PENDING_DISPATCH";case "IN_TRANSIT"->"IN_TRANSIT";case "COMPLETED"->"COMPLETED";default->"DISPATCHED";}; + transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion()); + if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo()); + } + /** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */ + public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { + var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); + String from=order.getStatus();if(target.equals(from))return; + boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target)); + if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致"); + order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order); + var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer); + history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key); + history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history); + } + /** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */ + private void resources(DispatchAggregate row,FleetTripEntity trip) { + row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources( + trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()), + trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null)))); + } + /** 保存明确的业务快照,失败时终止事务。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java new file mode 100644 index 000000000..5d6184daa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.springframework.stereotype.Repository; + +/** 运输关联持久化边界,不在此决定业务状态。 */ +@Repository +public class TransportTaskRepository { + private final TransportTaskMapper mapper; + /** 注入本域 Mapper。 */ + public TransportTaskRepository(TransportTaskMapper mapper) { this.mapper=mapper; } + /** 已确认完成凭证引用。 */ + public java.util.List evidence(String number){return mapper.evidence(number);} + /** 读取已成功执行的运输命令摘要。 */ + public String commandHash(String number,String key){return mapper.commandHash(number,key);} + /** 成功后记账。 */ + public void command(String number,String key,String hash,String evidence){mapper.command(number,key,hash,evidence);} + /** 读取原创建内容摘要。 */ + public String createHash(String trip){return mapper.createHash(trip);} + /** 保存创建内容摘要。 */ + public void createHash(String trip,String hash){mapper.createHashSave(trip,hash);} + /** 按资源记录恢复排车主单。 */ + public DispatchAggregate byTrip(long trip) { return mapper.byTrip(trip); } + /** 保存原本土卡车单关联。 */ + public void insert(TransportTaskModels.TruckLink row) { mapper.insert(row); } + /** 读取原单关联。 */ + public TransportTaskModels.TruckLink truck(String number) { return mapper.truck(number); } + /** 取消后允许原单重新安排。 */ + public void cancel(String number) { mapper.cancel(number); } + /** 解析主单对应资源业务号。 */ + public String tripNo(long id) { return mapper.tripNo(id); } + /** 最早的一百票待派车原单,安排成功后自动移出候选。 */ + public java.util.List candidates() { return mapper.candidates(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java new file mode 100644 index 000000000..848875bc1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import java.time.*; +import java.util.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */ +@Service +public class TransportTaskService { + private final DispatchAggregateRepository roots; + private final TransportTaskRepository links; + private final TransportTaskProjection projection; + private final FleetTripRepository tripRepo; + private final FleetTripService trips; + private final FleetPickupService pickups; + private final FleetPickupRepository pickupFacts; + private final UsTruckOrderRepository trucks; + private final FleetPoolOrderQueries pool; + private final DriverService drivers; + private final FleetTransportVehicleGuard vehicles; + private final TripOperationsEvidenceGateway files; + private final ObjectMapper json; + /** 复用已有资格、占用、原单及凭证能力。 */ + public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection, + FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts, + UsTruckOrderRepository trucks,FleetPoolOrderQueries pool,DriverService drivers,FleetTransportVehicleGuard vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) { + this.roots=roots;this.links=links;this.projection=projection;this.tripRepo=tripRepo;this.trips=trips;this.pickups=pickups;this.pickupFacts=pickupFacts; + this.trucks=trucks;this.pool=pool;this.drivers=drivers;this.vehicles=vehicles;this.files=files;this.json=json; + } + /** 待排本土卡车仅展示未被独立排车或拼车承运占用的原单。 */ + public List truckCandidates(){return links.candidates();} + /** 同仓取货沿用原任务规则,并注册一个真实排车主单。 */ + @Transactional + public TransportTaskModels.Detail createPickup(String operator,FleetPickupModels.Create request) { + var created=pickups.create(operator,request);var trip=tripRepo.lockTrip(created.trip().tripNo()); + var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator); + return detail(row.getDispatchNo()); + } + /** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */ + @Transactional + public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) { + String hash=hash(write(request));var prior=tripRepo.findCreated(operator,request.idempotencyKey()); + if(prior!=null)return replayCreate(prior,hash); + var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在"); + prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash); + pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId()); + if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车"); + if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址"); + var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(), + List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey())); + var trip=tripRepo.lockTrip(created.trip().tripNo());var row=projection.register(trip,1,value(order.getPalletCount()),value(order.getPackageCount()),operator); + links.insert(new TransportTaskModels.TruckLink(row.getDispatchNo(),trip.getTripNo(),order.getCustomerId(),order.getOrderNo()));links.createHash(trip.getTripNo(),hash); + projection.transition(order.getCustomerId(),order.getOrderNo(),"DISPATCHED","TRANSPORT_ASSIGN",operator,"接入排车:"+row.getDispatchNo(),request.idempotencyKey()); + return detail(row.getDispatchNo()); + } + /** 重放只返回同一原始请求,不受后续原单状态推进影响。 */ + private TransportTaskModels.Detail replayCreate(FleetTripEntity trip,String hash) { + if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !hash.equals(links.createHash(trip.getTripNo())))throw conflict("同一安排请求不能更换来源或时间"); + var row=links.byTrip(trip.getId());if(row==null)throw conflict("原安排尚未完成,请重试");return detail(row.getDispatchNo()); + } + /** 通过排车号读取运输执行,不要求访问已隐藏的管理页面。 */ + @Transactional(readOnly=true) + public TransportTaskModels.Detail detail(String number) { + var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number); + return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), + "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), + evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList()); + } + /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ + public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} + /** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */ + private List evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();} + /** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */ + public FleetTripEntity transport(String number,boolean lock) { + var root=roots.find(number);if(root==null)throw missing("排车单不存在"); + if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务"); + String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip; + } + /** 分配实际资源,容量超限时整次分配回滚。 */ + @Transactional + public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) { + var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request); + if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number); + } + /** 取消运输并保留原单、排车及资源审计。 */ + @Transactional + public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { + var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number); + } + /** 本土卡车发车和完成不制造仓库收发货记录。 */ + @Transactional + public TransportTaskModels.Detail truckCommand(String number,String operator,String action,TransportTaskModels.Command request) { + var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()))throw conflict("当前不是本土卡车运输任务"); + String hash=hash(action+":"+write(request));String prior=links.commandHash(number,request.idempotencyKey()); + if(prior!=null){if(!prior.equals(hash))throw conflict("同一请求标识不能提交不同内容");return detail(number);} + if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()))throw conflict("运输任务已变化,请刷新后重试"); + var order=truck(number); + if("depart".equals(action)) { + if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车"); + requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); + } else if("complete".equals(action)) { + if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车"); + if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一份本次运输完成凭证"); + for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id); + trip.setStatus("COMPLETED");trip.setCompletedAt(now()); + trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of()))); + } else throw conflict("不支持的运输动作"); + trips.recordExecution(trip,request.recordVersion(),"LOCAL_TRUCK_"+action.toUpperCase(Locale.ROOT),request.reason(),operator); + links.command(number,request.idempotencyKey(),hash,write(request.evidenceFileIds()==null?List.of():request.evidenceFileIds()));return detail(number); + } + /** 上传只接受当前任务执行期间的真实文件,文件服务强制归属此资源记录。 */ + @Transactional + public com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile upload(String number,String operator,MultipartFile file) { + var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前任务不能上传运输凭证"); + if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw conflict("请上传不超过10MB的凭证");return files.upload(trip.getId(),operator,file); + } + /** 原单在执行时锁定,不能通过普通原单手工推进绕过排车。 */ + private UsTruckOrder truck(String number){var link=links.truck(number);if(link==null)throw missing("本土卡车关联不存在");var order=trucks.lockNo(link.customerId(),link.orderNo());if(order==null)throw missing("本土卡车原单不存在");return order;} + /** 发车再次核对占用及证照,不把候选阶段校验当成执行资格。 */ + private void requireResources(FleetTripEntity trip) { + if(trip.getVehicleId()==null || trip.getDriverId()==null || !pickupFacts.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("请先分配有效车辆和司机"); + vehicles.validate(trip); + var driver=drivers.requireDispatchable(trip.getDriverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机"); + } + /** 原单实收托量、申报重量和体积均不得超过已分配车辆能力。 */ + private void capacity(FleetTripEntity trip,UsTruckOrder order) { + if(trip.getMaxPalletSpaces()==null || value(order.getPalletCount())>trip.getMaxPalletSpaces() || trip.getMaxLoadKg()==null || trip.getMaxVolumeCbm()==null + || (order.getTotalWeightKg()!=null && order.getTotalWeightKg().compareTo(trip.getMaxLoadKg())>0) + || (order.getTotalVolumeCbm()!=null && order.getTotalVolumeCbm().compareTo(trip.getMaxVolumeCbm())>0))throw conflict("本土卡车货量超过所选车辆托位、载重或容积"); + } + /** 可空业务数量按零显示,不产生虚构实收。 */ + private static int value(Integer value){return value==null?0:value;} + /** 固定 UTC 记录执行时刻。 */ + private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).withNano(0);} + /** 稳定序列化请求摘要。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + /** 不在账本保存原始隐私内容。 */ + private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} + /** 返回业务冲突。 */ + private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);} + /** 返回不可访问资源。 */ + private static ResponseStatusException missing(String value){return new ResponseStatusException(HttpStatus.NOT_FOUND,value);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java new file mode 100644 index 000000000..afa5e2bca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.apache.ibatis.annotations.*; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface FleetLooseboardDemandMapper { + /** 分页读取未匹配且没有独立实物履约的客户散板需求。 */ + @Select("") + Page pending(Page page,@Param("keyword") String keyword); + /** 原单锁内查询生效匹配,防止跨车次重复接单。 */ + @Select("SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customer} AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE") + Integer active(@Param("customer") String customer,@Param("id") long id); + /** 保存预订不可变来源,取消后保留历史。 */ + @Update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=#{id} WHERE id=#{booking} AND customer_id=#{customer} AND source_looseboard_order_id IS NULL") + int attach(@Param("booking") long booking,@Param("customer") String customer,@Param("id") long id); + /** 锁定当前预订对应的同客户原单。 */ + @Select("SELECT o.* FROM ll_local_looseboard_order o JOIN ll_fleet_external_booking b ON b.source_looseboard_order_id=o.id AND b.customer_id=o.customer_id WHERE b.id=#{booking} AND b.customer_id=#{customer} FOR UPDATE") + LocalLooseboardOrder source(@Param("booking") long booking,@Param("customer") String customer); + /** 批量读取草稿或取消记录的原单编号。 */ + @Select("") + List sourceNumbers(@Param("ids") Collection ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java new file mode 100644 index 000000000..bf7fd7cf3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; + +@Repository +public class FleetLooseboardDemandRepository { + private final FleetLooseboardDemandMapper demands; + private final LocalLooseboardOrderMapper orders; + /** 注入需求查询和原单仓储。 */ + public FleetLooseboardDemandRepository(FleetLooseboardDemandMapper demands,LocalLooseboardOrderMapper orders) { this.demands=demands;this.orders=orders; } + /** 分页读取待匹配原单。 */ + public Page pending(int page,int size,String keyword) { return demands.pending(Page.of(page,size),keyword); } + /** 锁定客户原单。 */ + public LocalLooseboardOrder lock(String customer,String number) { return orders.lockNo(customer,number); } + /** 核对生效匹配。 */ + public boolean active(String customer,long id) { return demands.active(customer,id)!=null; } + /** 关联不可变来源。 */ + public int attach(long booking,String customer,long id) { return demands.attach(booking,customer,id); } + /** 锁定匹配来源。 */ + public LocalLooseboardOrder source(long booking,String customer) { return demands.source(booking,customer); } + /** 批量读取原单编号。 */ + public List sourceNumbers(Collection ids) { return demands.sourceNumbers(ids); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java new file mode 100644 index 000000000..143b94503 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.Objects; + +@Service +public class FleetLooseboardDemandService { + private final FleetLooseboardDemandRepository repository; + /** 注入原单需求仓储。 */ + public FleetLooseboardDemandService(FleetLooseboardDemandRepository repository) { this.repository=repository; } + /** 已授权平台入口读取待匹配原单。 */ + @Transactional(readOnly=true) + public PageResult pending(int page,int size,String keyword) { + var rows=repository.pending(page,size,keyword); + return new PageResult<>(rows.getRecords().stream().map(Demand::from).toList(),rows.getCurrent(),rows.getSize(),rows.getTotal(),rows.getPages()); + } + public record Demand(String orderNo,String customerId,long version,String origin,String destination,String productName, + Integer estimatedPalletCount,Integer packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm, + String currency,String originContactName,String originContactPhone,String remark) { + /** 仅输出匹配所需资料,不泄露原单幂等键与请求哈希。 */ + static Demand from(LocalLooseboardOrder o) { return new Demand(o.getOrderNo(),o.getCustomerId(),o.getVersion(),o.getOrigin(),o.getDestination(),o.getProductName(),o.getEstimatedPalletCount(),o.getPackageCount(),o.getTotalWeightKg(),o.getTotalVolumeCbm(),o.getCurrency(),o.getOriginContactName(),o.getOriginContactPhone(),o.getRemark()); } + } + /** 锁定原单并校验客户、版本与唯一匹配,不预留容量。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public LocalLooseboardOrder prepare(String customer,String type,String number,Long version) { + if(number==null||number.isBlank()) { if(version!=null)throw conflict("来源版本必须与散板原单一起提交"); return null; } + if(!"LOCAL_LOOSEBOARD".equals(type))throw conflict("散板原单只能匹配本土散板承运"); + var order=repository.lock(customer,number); + if(order==null||version==null||!Objects.equals(version,order.getVersion()))throw conflict("散板原单不存在或已变化,请刷新待匹配需求"); + if(!"PENDING_ACCEPTANCE".equals(order.getStatus())||order.getLastlegOrderId()!=null||repository.active(customer,order.getId()))throw conflict("散板原单已有承运或独立履约,请核对原单"); + return order; + } + /** 原单锁保持到事务提交,跨车次匹配只允许成功一次。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void attach(long booking,LocalLooseboardOrder order) { + if(order!=null&&repository.attach(booking,order.getCustomerId(),order.getId())!=1)throw conflict("原单匹配失败,请刷新重试"); + } + /** 匹配后禁止改写业务类型或替换来源,允许省略已有来源参数。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void validateEdit(long booking,String customer,String type,String number) { + var source=repository.source(booking,customer); + if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(type)||number!=null&&!number.equals(source.getOrderNo())))throw conflict("已匹配散板原单不能替换,请取消登记后重新匹配"); + if(source==null&&number!=null&&!number.isBlank())throw conflict("请从待匹配散板需求创建新的登记"); + } + /** 返回业务冲突。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 19d19c6cb..d9c2ee625 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -20,6 +20,7 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; /** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */ @Service public class FleetPoolOrderBridge { + @jakarta.annotation.Resource private FleetLooseboardDemandRepository demands; private final FleetPoolOrderRepository repository; private final FleetPoolOrderNumberGenerator numbers; private final ObjectMapper json; @@ -47,16 +48,20 @@ public class FleetPoolOrderBridge { validate(in); String hash=hash(in); var prior=repository.lockBooking(in.bookingId()); if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);} + var source=demands.source(in.bookingId(),in.customerId()); + if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(in.businessType())||!"PENDING_ACCEPTANCE".equals(source.getStatus())||source.getLastlegOrderId()!=null))throw conflict("散板原单履约状态已变化"); var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); - row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId()); - row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId())); + row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId()); + row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo()); row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey()); row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions()); row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L); row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now); String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR"; - if("LOCAL_LOOSEBOARD".equals(in.businessType())) { + if(source!=null) { + source.setStatus("WAITING_INBOUND");source.setVersion(source.getVersion()+1);source.setUpdatedAt(now);repository.updateLoose(source); + } else if("LOCAL_LOOSEBOARD".equals(in.businessType())) { var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); order.setTransportType(transport); // 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。 @@ -79,7 +84,7 @@ public class FleetPoolOrderBridge { order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows. } - repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); + repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now); return ref(row); } /** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */ @@ -177,6 +182,7 @@ public class FleetPoolOrderBridge { /** 批量读取预订的业务编号。 */ public Map businessOrderNumbers(Collection ids) { if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap(); + demands.sourceNumbers(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo())); repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; } static void validate(ConfirmedBooking in) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java index 090edfa2b..b87613189 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java @@ -38,7 +38,9 @@ public interface FleetPoolOrderMapper extends BaseMapper { FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE") List lockDispatch(@Param("customerId") String customerId,@Param("no") String no); - @Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}") + /** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */ + @Select("SELECT COALESCE((SELECT 1 FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id} LIMIT 1 FOR UPDATE),0) + " + + "COALESCE((SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customerId} AND #{type}='LOCAL_LOOSEBOARD' AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE),0)") int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id); @Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}") int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index b5f54bde1..a474e2a46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -36,7 +36,16 @@ public final class FleetCapacityOfferModels { @NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode, Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount, @NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef, - @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @Size(max=64) String sourceOrderNo,@Min(0) Long sourceOrderVersion) { + /** 保留无原单人工登记的构造方式。 */ + public InquiryWrite(Long tripRecordVersion,Long offerRecordVersion,Long recordVersion,Long customerId, + String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount, + FleetBookingModels.Cargo cargo,FleetQuoteCandidateModels.QuoteRef quoteRef,String idempotencyKey) { + this(tripRecordVersion,offerRecordVersion,recordVersion,customerId,businessType,deliveryMode, + estimatedArrivalAt,expectedPalletCount,cargo,quoteRef,idempotencyKey,null,null); + } + } public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion, @NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index ca291fe1a..eb0ca1ca7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -25,6 +25,7 @@ import java.util.*; @Service public class FleetCapacityOfferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands; public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage"; private final FleetCapacityOfferMapper offers; private final FleetCapacityOfferCommandMapper commands; @@ -50,6 +51,11 @@ public class FleetCapacityOfferService { this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService; this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock; } + /** 平台需求池分页读取客户已下的散板原单。 */ + @Transactional(readOnly=true) + public PageResult pendingDemands(FleetCapacityOfferModels.InquiryQuery query) { + writePermission("inquiry-create");return demands.pending(query.page(),query.size(),query.keyword()); + } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult page(FleetCapacityOfferModels.Query query){ readPermission(); @@ -171,17 +177,20 @@ public class FleetCapacityOfferService { touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false); saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result; } + /** 同一事务登记意向及原单来源,确认前不占位。 */ @Transactional public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){ writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true); var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class); if(replay!=null)return replayed(replay); versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request); + var source=demands.prepare(request.customerId().toString(),request.businessType(),request.sourceOrderNo(),request.sourceOrderVersion()); var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef())); if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对"); var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request); - bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result; + bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result; } + /** 编辑登记资料时保持客户及原单来源不可变。 */ @Transactional public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){ writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo()); @@ -189,6 +198,7 @@ public class FleetCapacityOfferService { var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay); versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request); if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记"); + demands.validateEdit(booking.getId(),booking.getCustomerId(),request.businessType(),request.sourceOrderNo()); if(request.recordVersion()==null)throw bad("缺少意向版本"); bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef())); booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 9f8e22d1e..6ab3ee073 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -276,7 +276,7 @@ public class FleetTripExecutionService { } /** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */ private static void requireVersionAndPlan(FleetTripEntity trip, long version) { - if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作"); + if (java.util.Set.of("PICKUP","LOCAL_TRUCK").contains(java.util.Objects.toString(trip.getTripPurpose(),""))) throw conflict("运输排车请使用对应运输任务操作"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 5f9bcb3e6..8f26fd1ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -27,6 +27,7 @@ import java.util.*; @Service public class FleetTripService { + @org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripRepository repository; @@ -101,6 +102,12 @@ public class FleetTripService { return createPlan(operator, request, "PICKUP"); } + /** 本土卡车运输使用同一资源占用与司机资格规则。 */ + @Transactional + public FleetTripModels.Detail createLocalTruck(String operator, FleetTripModels.Create request) { + return createPlan(operator, request, "LOCAL_TRUCK"); + } + /** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { @@ -376,7 +383,7 @@ public class FleetTripService { /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { - String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); + String hash = hash(("DELIVERY".equals(purpose) ? "" : purpose+":") + write(request)); var existing = repository.findCreated(operator, request.idempotencyKey()); if (existing != null) return replay(existing, hash); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); @@ -447,7 +454,7 @@ public class FleetTripService { bookingReservations.requireRoute(trip, request.stops()); if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt()); boolean unchangedRoute = response(trip).stops().equals(request.stops()); - if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); + if (Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")) && !unchangedRoute) throw conflict("运输路线由原单确定,不能修改来源地址"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); @@ -643,6 +650,7 @@ public class FleetTripService { } /** 为当前版本保存操作人、原因和完整车次历史。 */ private void record(FleetTripEntity trip, String action, String reason, String operator) { + if(transportEvents!=null)transportEvents.publishEvent(new TransportTripChanged(trip,action,reason,operator)); workChanges.changed(trip); var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index a54777c1d..a39fa512d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -29,6 +29,9 @@ public class PlatformFleetCapacityOfferController { public ApiResponse resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));} @PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") public ApiResponse close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));} + /** 读取客户已提交、等待车次匹配的散板原单。 */ + @GetMapping("/pending-looseboard-demands") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))") + public ApiResponse> pendingDemands(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.pendingDemands(query));} @GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") public ApiResponse> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));} @GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index d2d5fa6ee..1ba167f07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -51,7 +51,7 @@ public class LocalLooseboardService { @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private ObjectMapper objectMapper; - /** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */ + /** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */ @Transactional public LocalLooseboardResponses.Detail create(String customerId, String operatorId, LocalLooseboardRequests.Create request) { @@ -75,8 +75,7 @@ public class LocalLooseboardService { order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); - LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); - order.setLastlegOrderId(lastlegOrder.getId()); + // 下单只保存池内需求,实际收货时再创建唯一实物尾程单。 try { repository.insertOrder(order); } catch (DuplicateKeyException exception) { @@ -86,8 +85,7 @@ public class LocalLooseboardService { } history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, key, null, request.remark(), now); - lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(), - "Local looseboard order submitted", now); + return detail(customerId, order.getOrderNo()); } @@ -158,6 +156,7 @@ public class LocalLooseboardService { } if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); String next = norm(request.status()); + if (order.getLastlegOrderId() == null && !CANCELLED.equals(next)) throw conflict("请从散板需求池匹配车次,沿实际收货、排车和签收推进"); if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status"); String from = order.getStatus(); if (next.equals(from)) return response(order); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java index f6c43085d..f7e1c5a07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface UsTruckOrderMapper extends BaseMapper { + /** 原单被有效排车接管后,禁止独立改状态绕过运输事实。 */ + @Select("SELECT COUNT(*) FROM ll_dispatch_truck_task WHERE customer_id=#{customer} AND order_no=#{number} AND active_order IS NOT NULL") + long activeDispatch(@Param("customer") String customer,@Param("number") String number); @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java index 603047e03..aa238163f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java @@ -44,6 +44,8 @@ public class UsTruckOrderRepository { public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); } /** 在当前事务中按客户和订单号加行锁。 */ public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); } + /** 有效运输任务持有原单的执行权。 */ + public boolean hasActiveDispatch(String customer,String number) { return orders.activeDispatch(customer,number)>0; } /** 按客户和创建幂等键读取。 */ public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); } /** 保存新订单。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index e2537549f..8501c21a9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -396,7 +396,7 @@ public class UsTruckOrderService { } /** 按客户范围锁单并校验独立订单资格。 */ - private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); if(repository.hasActiveDispatch(customerId,orderNo))throw conflict("该单已接入排车,请在排车列表执行运输操作"); return o; } /** 复核创建请求摘要并返回既有订单。 */ private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } /** 读取订单资料与实际托盘和操作记录。 */ diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e8617c50a..0a184629b 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1208,6 +1208,7 @@ CREATE TABLE ll_fleet_external_booking ( binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_hash CHAR(64) NULL COMMENT '关联发运摘要', offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源', sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', @@ -1222,6 +1223,7 @@ CREATE TABLE ll_fleet_external_booking ( KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status), PRIMARY KEY(id), UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), @@ -1898,6 +1900,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( CREATE TABLE IF NOT EXISTS ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', @@ -4639,3 +4642,28 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; + + +-- 运输原单接入现有排车,不产生仓内库存事实。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml new file mode 100644 index 000000000..3b4cf3ba8 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -0,0 +1,99 @@ +openapi: 3.0.3 +info: + title: 现有排车运输任务 + version: 1.0.0 + description: >- + 平台主体(tenantId=0)在现有排车列表内安排到仓取货、本土卡车,使用真实PC排车号。 + 请求经统一Gateway与Bearer认证;JSON响应HTTP200,业务code=200成功,A0400参数错误、A0403权限不足、A0404不存在、A0409版本或状态冲突。 + list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。 + 创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。 + 到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。 +servers: + - url: /api/platform/v1/last-leg/dispatches/transport-tasks +security: + - bearerAuth: [] +paths: + /pickup-candidates: + get: + summary: 同仓上门取货候选(list,page/size/keyword/warehouseId) + responses: &response + '200': + description: 业务响应信封;data为对应详情、候选或操作结果 + /truck-candidates: + get: + summary: 待派车且未被运输或拼车占用的本土卡车候选(list) + responses: *response + /pickup: + post: + summary: 同一海外仓多客户取货安排(create) + description: sources为customerId/transferNo数组,plannedDepartureAt和expectedDriverEndAt为ISO时刻;idempotencyKey必填,remark可选。返回Detail。 + responses: *response + /truck: + post: + summary: 本土卡车原单接入排车(create) + description: customerId、orderNo、warehouseId、plannedDepartureAt、expectedDriverEndAt、idempotencyKey必填;原单须PENDING_DISPATCH且未被占用。返回Detail。 + responses: *response + /{number}: + parameters: &number + - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } + get: + summary: 排车运输详情(list) + description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。 + responses: *response + /{number}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: action, in: path, required: true, schema: { type: string, enum: [assign, cancel, resource-occupancy, release] } } + post: + summary: 分配、取消、资源可用性查询与实际任务释放 + description: >- + assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。 + resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。 + release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。 + responses: *response + /{number}/{kind}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: kind, in: path, required: true, schema: { type: string, enum: [pickup, truck] } } + - { name: action, in: path, required: true, schema: { type: string, enum: [depart, complete] } } + post: + summary: 运输发车(depart)或完成(edit) + description: recordVersion、idempotencyKey、reason必填。本土卡车complete还须至少一份evidenceFileIds,文件归属本任务。取货complete要求所有原单已送仓。 + responses: *response + /{number}/pickup/{customer}/{source}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: customer, in: path, required: true, schema: { type: string } } + - { name: source, in: path, required: true, schema: { type: string }, description: 仓储转运单号 } + - { name: action, in: path, required: true, schema: { type: string, enum: [picked, arrive, files] } } + post: + summary: 按原单上传凭证、确认实提或送仓(edit) + description: files使用multipart字段file;picked使用recordVersion、idempotencyKey、actualPackages、evidenceFileIds、remark;arrive使用recordVersion、idempotencyKey、reason。凭证不能跨客户原单使用。 + responses: *response + /{number}/truck/files: + parameters: *number + post: + summary: 本土卡车凭证上传(edit) + description: multipart字段file,非空且不超过10MB,只能在计划或运输中上传。上传不等于完成,须通过complete命令确认文件引用。 + responses: *response + /{number}/truck/files/{fileId}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 已确认本土卡车完成凭证鉴权下载(list) + responses: + '200': { description: application/octet-stream文件;失败返回业务响应信封 } + /{number}/pickup/{customer}/{source}/files/{fileId}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: customer, in: path, required: true, schema: { type: string } } + - { name: source, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 当前排车原单已确认取货凭证鉴权下载(list) + responses: + '200': { description: application/octet-stream文件;当前排车及原单归属不符时拒绝 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 52163e41b..3389fbc00 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -373,6 +373,36 @@ paths: application/json: schema: $ref: '#/components/schemas/OfferCommand' + /api/platform/v1/last-leg/fleet/capacity-offers/pending-looseboard-demands: + get: + summary: 分页读取客户已提交的待匹配散板原单 + description: 权限:platform:fleet:capacity-offer:manage 或 (platform:fleet:capacity-offer:list 且 platform:fleet:capacity-offer:inquiry-create)。仅返回待接单、没有独立尾程来源且没有生效匹配的原单;取消未确认登记后恢复候选。下单及登记不占位,确认承运才占位。旧独立履约单不回填来源。 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: keyword, in: query, schema: { type: string, maxLength: 128 } } + responses: + '200': + description: 统一业务响应 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + message: { type: string } + data: + type: object + properties: + records: + type: array + items: { $ref: '#/components/schemas/LooseboardDemand' } + page: { type: integer } + size: { type: integer } + total: { type: integer } + pages: { type: integer } + '401': { description: 未登录 } + '403': { description: 无需求匹配权限 } /api/platform/v1/last-leg/fleet/capacity-offers/inquiries: get: summary: 分页查询本人或获准的全部意向 @@ -1310,6 +1340,24 @@ components: minimum: 0 - type: string pattern: ^[0-9]+$ + LooseboardDemand: + type: object + description: 原单待匹配投影;不含创建幂等键、请求哈希和操作人技术身份。 + properties: + orderNo: { type: string } + customerId: { type: string } + version: { type: integer, format: int64 } + origin: { type: string } + destination: { type: string } + productName: { type: string, nullable: true } + estimatedPalletCount: { type: integer, nullable: true } + packageCount: { type: integer, nullable: true } + totalWeightKg: { type: number, nullable: true } + totalVolumeCbm: { type: number, nullable: true } + currency: { type: string } + originContactName: { type: string, nullable: true } + originContactPhone: { type: string, nullable: true } + remark: { type: string, nullable: true } InquiryWrite: type: object required: @@ -1321,6 +1369,18 @@ components: - cargo - idempotencyKey properties: + sourceOrderNo: + type: string + maxLength: 64 + nullable: true + description: 从待匹配散板选择原商业单;仅LOCAL_LOOSEBOARD支持。确认承运复用原单,不另开商业单。 + sourceOrderVersion: + type: integer + format: int64 + minimum: 0 + nullable: true + description: 创建匹配时与原单号一起提交;已匹配登记不可替换来源。 + tripRecordVersion: oneOf: - type: integer diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index add73ae54..61a4e77c3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -442,6 +442,23 @@ public class DispatchAggregatePersistenceTest { assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10); } + /** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */ + @Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception { + var f=new Fixture(); + var pickup=f.tx.execute(s->f.service.create(f.actor,f.create("transport-pickup-list",List.of()))); + var truck=f.tx.execute(s->f.service.create(f.actor,f.create("transport-truck-list",List.of()))); + String pickupTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,pickup.dispatchNo()); + String truckTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,truck.dispatchNo()); + f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",pickup.dispatchNo()); + f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",truck.dispatchNo()); + f.db.update("INSERT INTO ll_fleet_pickup_task(customer_id,trip_no) VALUES('PICKUP-CUSTOMER',?)",pickupTrip); + f.db.update("INSERT INTO ll_dispatch_truck_task(dispatch_no,customer_id) VALUES(?,'TRUCK-CUSTOMER')",truck.dispatchNo()); + var pickupPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"PICKUP-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null))); + var truckPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"TRUCK-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null))); + assertThat(pickupPage.total()).isOne();assertThat(pickupPage.records().get(0).dispatchNo()).isEqualTo(pickup.dispatchNo());assertThat(pickupPage.records().get(0).taskType()).isEqualTo("PICKUP"); + assertThat(truckPage.total()).isOne();assertThat(truckPage.records().get(0).dispatchNo()).isEqualTo(truck.dispatchNo());assertThat(truckPage.records().get(0).taskType()).isEqualTo("LOCAL_TRUCK"); + } + /** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */ @Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception { var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5); @@ -492,6 +509,8 @@ public class DispatchAggregatePersistenceTest { db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)"); db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)"); db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)"); + db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(64),customer_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_fleet_pickup_task(customer_id VARCHAR(128),trip_no VARCHAR(64))"); db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java new file mode 100644 index 000000000..0465b9a48 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.TransportTripChanged; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +class TransportTaskProjectionTest { + /** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */ + @Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){ + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class); + var projection=new TransportTaskProjection(roots,links,trucks,mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules()); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setRecordVersion(3L);root.setTaskType("LOCAL_TRUCK"); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,3L)).thenReturn(1); + when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1")); + var order=new UsTruckOrder();order.setId(9L);order.setStatus("DISPATCHED");order.setVersion(5L);when(trucks.lockNo("C1","U1")).thenReturn(order); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("CANCELLED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now()); + projection.changed(new TransportTripChanged(trip,"CANCEL","取消运输","OP")); + assertThat(root.getStatus()).isEqualTo("CANCELLED");assertThat(root.getRecordVersion()).isEqualTo(4L);assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");assertThat(order.getVersion()).isEqualTo(6L); + verify(links).cancel("PC1");verify(trucks).insertHistory(argThat(row->"DISPATCHED".equals(row.getFromStatus())&&"PENDING_DISPATCH".equals(row.getToStatus())&&"C1".equals(row.getCustomerId()))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java new file mode 100644 index 000000000..8279ac045 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 验证原单承运执行权、凭证隔离和成功请求重放,不能仅靠页面按钮门禁。 */ +class TransportTaskServiceTest { + DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips; + UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order; + final Map commands=new HashMap<>(); + @BeforeEach void setup(){ + roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class); + trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class); + service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()); + var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L); + when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1"); + when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of()); + trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("IN_TRANSIT");trip.setRecordVersion(3L); + when(tripRepo.lockTrip("TR1")).thenReturn(trip);when(tripRepo.findTrip("TR1")).thenReturn(trip); + order=new UsTruckOrder();order.setStatus("IN_TRANSIT");when(trucks.lockNo("C1","U1")).thenReturn(order); + when(links.commandHash(eq("PC1"),anyString())).thenAnswer(call->commands.get(call.getArgument(1))); + doAnswer(call->{commands.put(call.getArgument(1),call.getArgument(2));return null;}).when(links).command(eq("PC1"),anyString(),anyString(),anyString()); + doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + } + /** 无凭证或跨任务文件均不能结束运输,也不能写成功账本。 */ + @Test void completionRequiresOwnedEvidence(){ + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-missing",List.of()))).hasMessageContaining("凭证"); + when(files.metadata(1L,99L)).thenThrow(new IllegalArgumentException("跨任务凭证")); + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-foreign",List.of(99L)))).hasMessageContaining("跨任务"); + verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());verify(links,never()).command(anyString(),anyString(),anyString(),anyString()); + } + /** 同请求在版本变化后仍恢复原成功结果,不重复推进原单。 */ + @Test void replayUsesOriginalSuccessfulCommandBeforeCurrentVersion(){ + var body=command("complete-key",List.of(77L));service.truckCommand("PC1","OP","complete",body);service.truckCommand("PC1","OP","complete",body); + assertThat(trip.getStatus()).isEqualTo("COMPLETED");verify(trips,times(1)).recordExecution(any(),eq(3L),eq("LOCAL_TRUCK_COMPLETE"),anyString(),eq("OP")); + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("complete-key",List.of(88L)))).hasMessageContaining("不同内容"); + } + /** 旧版本和未发车原单均不能利用完成接口跳过真实执行。 */ + @Test void staleVersionAndWrongOriginalStatusCannotComplete(){ + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",new TransportTaskModels.Command(2L,"stale-key","结束运输",List.of(77L)))).hasMessageContaining("刷新"); + order.setStatus("DISPATCHED");assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("wrong-state",List.of(77L)))).hasMessageContaining("先确认发车");verifyNoInteractions(files); + } + /** 下载只允许已完成命令持久化的文件引用,不能凭共享文件 ID 访问。 */ + @Test void evidenceDownloadRequiresPersistedConfirmation(){ + assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); + } + /** 构造固定版本的测试执行命令。 */ + private TransportTaskModels.Command command(String key,List files){return new TransportTaskModels.Command(3L,key,"结束运输",files);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java new file mode 100644 index 000000000..79eec9804 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class FleetLooseboardDemandPersistenceTest { + /** 真实 SQL 验证同客户关联、唯一来源、取消恢复候选和跨客户隔离。 */ + @Test void originalDemandLeavesPoolUntilDraftIsCancelled() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_local_looseboard_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),order_no VARCHAR(64),status VARCHAR(32),lastleg_order_id BIGINT,origin VARCHAR(64),destination VARCHAR(64),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),source_looseboard_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(customer_id VARCHAR(128),business_type VARCHAR(32),business_order_id BIGINT)"); + jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(1,'C-1','LB-1','PENDING_ACCEPTANCE',NULL,'LA','Ontario',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(2,'C-1','OLD','PENDING_ACCEPTANCE',99,'LA','Ontario',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(10,'C-1',NULL,'DRAFT'),(11,'C-2',NULL,'DRAFT')"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(FleetLooseboardDemandMapper.class);config.addMapper(FleetPoolOrderMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject()); + var mapper=session.getMapper(FleetLooseboardDemandMapper.class); + var links=session.getMapper(FleetPoolOrderMapper.class); + assertThat(mapper.pending(Page.of(1,20),"Ontario").getRecords()).extracting("orderNo").containsExactly("LB-1"); + assertThat(mapper.attach(10,"C-2",1)).isZero(); + assertThat(mapper.attach(10,"C-1",1)).isEqualTo(1); + assertThat(mapper.attach(10,"C-1",2)).isZero(); + assertThat(mapper.active("C-1",1)).isEqualTo(1); + assertThat(mapper.active("C-2",1)).isNull(); + assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isEqualTo(1); + assertThat(mapper.source(10,"C-2")).isNull(); + assertThat(mapper.source(10,"C-1").getOrderNo()).isEqualTo("LB-1"); + assertThat(mapper.pending(Page.of(1,20),null).getRecords()).isEmpty(); + jdbc.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=10"); + assertThat(mapper.active("C-1",1)).isNull(); + assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isZero(); + assertThat(mapper.pending(Page.of(1,20),null).getRecords()).extracting("orderNo").containsExactly("LB-1"); + assertThat(mapper.sourceNumbers(List.of(10L,11L))).extracting(FleetPoolOrderLink::getBusinessOrderNo).containsExactly("LB-1"); + jdbc.update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=1 WHERE id=11"); + assertThat(mapper.source(11,"C-2")).isNull(); + assertThat(mapper.sourceNumbers(List.of(11L))).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java new file mode 100644 index 000000000..501c3a182 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetLooseboardDemandServiceTest { + final FleetLooseboardDemandRepository repository=mock(FleetLooseboardDemandRepository.class); + final FleetLooseboardDemandService service=new FleetLooseboardDemandService(repository); + /** 校验客户隔离、版本、已有匹配与独立履约都阻止重复接单。 */ + @Test void rejectsMissingStaleMatchedAndPhysicalSources() { + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("不存在"); + var row=source();when(repository.lock("9","LB-1")).thenReturn(row); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",1L)).hasMessageContaining("变化"); + assertThatThrownBy(()->service.prepare("9","US_TRUCK","LB-1",0L)).hasMessageContaining("散板"); + when(repository.active("9",1)).thenReturn(true); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运"); + when(repository.active("9",1)).thenReturn(false);row.setLastlegOrderId(7L); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("独立履约"); + verify(repository,never()).attach(anyLong(),anyString(),anyLong()); + } + /** 取消草稿使生效匹配清空,同张原单可重新选车且没有商业复制。 */ + @Test void allowsRematchAfterDraftCancellationAndKeepsSourceImmutable() { + var row=source();when(repository.lock("9","LB-1")).thenReturn(row); + when(repository.active("9",1)).thenReturn(true,false); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运"); + assertThat(service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).isSameAs(row); + when(repository.attach(7,"9",1)).thenReturn(1);service.attach(7,row); + when(repository.source(7,"9")).thenReturn(row); + service.validateEdit(7,"9","LOCAL_LOOSEBOARD",null); + assertThatThrownBy(()->service.validateEdit(7,"9","US_TRUCK",null)).hasMessageContaining("不能替换"); + assertThatThrownBy(()->service.validateEdit(7,"9","LOCAL_LOOSEBOARD","LB-2")).hasMessageContaining("不能替换"); + } + /** 构造待匹配原单。 */ + private LocalLooseboardOrder source() { + var row=new LocalLooseboardOrder();row.setId(1L);row.setCustomerId("9");row.setOrderNo("LB-1");row.setVersion(0L);row.setStatus("PENDING_ACCEPTANCE");return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index f1cd3e630..7a8594469 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -25,11 +25,13 @@ class FleetPoolOrderBridgeTest { FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); + FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class); FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); + org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands); when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); @@ -45,6 +47,23 @@ class FleetPoolOrderBridgeTest { BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");} void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");} + /** 原单匹配后全链复用客户商业单,实收到签收均不另建商业单。 */ + @Test void matchedSourceRemainsTheSameCommercialOrderThroughDelivery() { + looseOrder=new LocalLooseboardOrder();looseOrder.setId(31L);looseOrder.setCustomerId("9");looseOrder.setOrderNo("LB-ORIGINAL");looseOrder.setVersion(4L);looseOrder.setStatus("PENDING_ACCEPTANCE"); + looseOrder.setOrigin("客户原始提货地址");looseOrder.setDestinationContactName("原始收货人"); + when(demands.source(7,"9")).thenReturn(looseOrder);when(loose.lockNo("9","LB-ORIGINAL")).thenAnswer(a->looseOrder); + var original=looseOrder; + var confirmed=service.confirm(input("LOCAL_LOOSEBOARD"),"op"); + assertThat(confirmed.businessOrderNo()).isEqualTo("LB-ORIGINAL");assertThat(row.getBusinessOrderId()).isEqualTo(31L); + assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(confirmed); + incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op"); + service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); + service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now()); + assertThat(looseOrder).isSameAs(original);assertThat(original.getStatus()).isEqualTo("COMPLETED"); + assertThat(original.getLastlegOrderId()).isEqualTo(77L);assertThat(original.getDestinationContactName()).isEqualTo("原始收货人"); + verify(loose,never()).insert(any(LocalLooseboardOrder.class)); + } + @Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() { service.confirm(input("LOCAL_LOOSEBOARD"),"op"); incoming(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 61d092e6c..3a3546be2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -47,7 +47,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(46); + assertThat(count).isEqualTo(47); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 910d83b73..eeaa9a59a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -78,6 +78,18 @@ class FleetTripAuthorizationTest { assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class); } finally {SecurityContextHolder.clearContext();} } + /** 需求含客户原单资料,仅列表权限不能读取待匹配需求。 */ + @Test void pendingDemandRequiresManageOrListAndCreate() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformFleetCapacityOfferController.class); + var service=context.getBean(FleetCapacityOfferService.class); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); + assertThatThrownBy(()->controller.pendingDemands(null)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(service); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST),new SimpleGrantedAuthority("platform:fleet:capacity-offer:inquiry-create")))); + controller.pendingDemands(null);verify(service).pendingDemands(null); + } finally {SecurityContextHolder.clearContext();} + } @Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() { var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create"); try(var context=new AnnotationConfigApplicationContext(Config.class)) { @@ -97,6 +109,10 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + /** 原单需求依赖使用替身,权限测试不访问数据库。 */ + @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); } + /** 桥接器的原单查询使用仓储替身。 */ + @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); } /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index c89e42be6..af2ceb30e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -64,6 +64,9 @@ class LocalLooseboardServiceTest { ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + // 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。 + var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100"); + org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail); org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) .thenReturn("LB-WH-20260901-000001"); org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString())) @@ -94,12 +97,8 @@ class LocalLooseboardServiceTest { assertEquals("家居用品", detail.summary().productName()); assertEquals(3, detail.summary().estimatedPalletCount()); assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt()); - verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> - "LOCAL_LOOSEBOARD".equals(value.getOrderType()) - && value.getSourceOrderNo().equals("LB-WH-20260901-000001") - && value.getInboundCode() == null - && value.getPalletCount() == 0 - && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); + org.junit.jupiter.api.Assertions.assertNull(saved.get().getLastlegOrderId()); + verify(lastlegOrders, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> "CREATE".equals(value.getOperationType()))); } @@ -208,7 +207,7 @@ class LocalLooseboardServiceTest { private LocalLooseboardOrder order(String status, long version) { LocalLooseboardOrder order = new LocalLooseboardOrder(); - order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); + order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); order.setLastlegOrderId(99L); order.setPalletRequired("YES"); order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 578a127a9..c8337ab79 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,21 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService", + "target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository", + "reason": "本土卡车接入现有排车的本地事务编排:创建和执行先锁原单,防止普通原单命令并发推进;仅允许此编排器通过Repository访问,不批准Mapper或跨服务SQL。" + }, + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService", + "target": "com.baiyue.lastleg.trucking.entity.UsTruckOrder", + "reason": "同一锁单事务核对本土卡车真实起终点、实收托数和申报载重容积,不能用浏览器载荷替代原单;不构造库存或向API暴露原单写模型。" + }, + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskProjection", + "target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository", + "reason": "同步运输事件与本土卡车状态必须同事务提交和回滚,旧内部执行入口也要同步原单并追加原单历史;取消释放有效原单关联,不合并客户和仓库归属。" + }, { "source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels", "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", @@ -355,6 +370,26 @@ "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandMapper", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository", + "target": "com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" } ], "applicationDependencyRule": { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index fcaf50e23..ceed15f86 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -1743,7 +1743,7 @@ components: name: mainStatus in: query required: false - description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。 + description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 schema: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] diff --git a/scripts/README.md b/scripts/README.md index 1cc09c76b..d2307f60d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。 + `local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。 `local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。 diff --git a/scripts/local-dispatch-transport-acceptance.mjs b/scripts/local-dispatch-transport-acceptance.mjs new file mode 100644 index 000000000..6b3f7d63c --- /dev/null +++ b/scripts/local-dispatch-transport-acceptance.mjs @@ -0,0 +1,186 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { randomUUID, createHash } from 'node:crypto' + +// 仅针对显式指定的本机统一网关;业务事实全部使用正式 HTTP 创建。 +const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('必须显式指定本机 Gateway') +if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少显式验收凭据') +const output = resolve(process.env.TRANSPORT_ACCEPTANCE_OUTPUT || 'reports/local-dispatch-transport.json') +const report = process.env.TRANSPORT_ACCEPTANCE_RESUME ? JSON.parse(await readFile(output, 'utf8')) : { marker: `DT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`, evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_TRANSPORT', checks: [], documents: {}, requests: {}, intents: [] } +if (report.passed) throw new Error('本批次已通过,请直接查看报告') +const marker = report.marker, base = '/api/platform/v1/last-leg/dispatches/transport-tasks', transfers = '/api/platform/v1/last-leg/warehouse-transfers', trucks = '/api/platform/v1/last-leg/us-truck-orders' +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +let admin, pda +async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) } +function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw new Error(label) } +function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body } +async function api(token, method, path, body, expected = '200') { + const form = body instanceof FormData + if (method !== 'GET' && !path.startsWith('/api/auth/')) { report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, hash: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex') }); await save() } + const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) }) + const value = await response.json().catch(() => null), code = String(value?.code) + const passed = response.status === 200 && code === expected + report.checks.push({ method, path, code, http: response.status, expected, passed, message: code === '200' ? undefined : value?.message, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }); await save() + if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`) + return expected === '200' ? value.data : value +} +const call = (method, path, body, expected) => api(admin, method, path, body, expected) +const request = (key, body) => report.requests[key] ||= { ...body, idempotencyKey: randomUUID() } +const read = number => call('GET', `${base}/${number}`) +const command = detail => ({ recordVersion: detail.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: `${marker} 本机运输流程验收` }) +async function act(detail, suffix, extra = {}, expected) { return call('POST', `${base}/${detail.dispatchNo}/${suffix}`, { ...command(detail), ...extra }, expected) } +async function transfer(customer, warehouse, key) { + if (report.documents[key]) return report.documents[key] + const body = request(key, { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '本机验收收货员', privateDestinationContactPhone: '0000000000', deliveryAddress: `1580 TEST Pickup Ave, Ontario, CA (${marker}-${key})`, pickupContactName: '本机验收取货员', pickupContactPhone: '0000000000', packageCount: 3, volumeCbm: 0, deliveryMethod: 'WAREHOUSE_PICKUP', estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), estimatedShipAt: new Date(Date.now()+172800000).toISOString().slice(0,19), remark: marker, needRelabel: false, items: [{ skuCode: `${marker}-${key}`, productNameZh: '本机验收收纳盒', quantity: 24, packageCount: 3, shippingMark: `${marker}-${key}` }] }) + await save() + let detail = await call('POST', `${transfers}?customerId=${customer.id}`, body) + detail = await call('POST', `${transfers}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: marker }) + report.documents[key] = { customerId: customer.id, transferNo: detail.transferNo, packageCount: detail.packageCount, cartonMarks: detail.cartons.map(row=>row.cartonMark) }; await save(); return report.documents[key] +} +async function pickup(sources, key) { + if (report.documents[key]) return read(report.documents[key].dispatchNo) + const body = request(key, { sources: sources.map(({customerId,transferNo})=>({customerId,transferNo})), plannedDepartureAt: new Date(Date.now()-300000).toISOString(), expectedDriverEndAt: new Date(Date.now()+86400000).toISOString(), remark: marker }) + await save(); const detail = await call('POST', `${base}/pickup`, body) + report.documents[key] = { dispatchNo: detail.dispatchNo, sources: body.sources }; await save() + const replay = await call('POST', `${base}/pickup`, body); check(replay.dispatchNo === detail.dispatchNo, '到仓取货重放返回同一排车号'); return detail +} +async function resources(warehouse) { + if (report.resources) return report.resources + const driverBase = '/api/platform/v1/last-leg/fleet/drivers' + let driver = await call('POST', driverBase, { name: `${marker}专用司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now()+365*86400000).toISOString().slice(0,10), remark: `${marker} TEST licence image`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } }) + driver = await call('POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence')) + driver = await call('POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本机模拟资质审核' }) + const vehicle = await call('POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-DT-${marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km:22, mileageKm:18000, driverOrDispatchCompany:marker, phone:'0000000000', region:'OVERSEAS', ownership:'OWNED', status:'IN_SERVICE', remark:marker, maxPalletSpaces:12,maxLoadKg:8000,maxVolumeCbm:45 }) + report.resources = { vehicleId:vehicle.id,driverId:driver.id }; await save(); return report.resources +} +async function assign(detail, resource) { if(detail.execution.trip.vehicleId) return detail; return call('POST', `${base}/${detail.dispatchNo}/assign`, { recordVersion:detail.execution.trip.recordVersion, ...resource, reason:marker }) } +async function release(detail) { + for(const kind of ['VEHICLE','DRIVER']) { + detail = await read(detail.dispatchNo) + if(detail.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) continue + await act(detail,'release',{resourceKind:kind,actualEndAt:new Date().toISOString(),endLocation:'Ontario TEST warehouse',vehicleStatus:kind==='VEHICLE'?'IN_SERVICE':null}) + } + return read(detail.dispatchNo) +} +async function truck(customer,key) { + if(report.documents[key]) return call('GET',`${trucks}/${report.documents[key].orderNo}?customerId=${customer.id}`) + const body=request(key,{vehicleType:'PALLET_15',origin:'TEST取货地',originAddress:'100 TEST Origin Road, Ontario, CA',destination:'TEST送货地',destinationAddress:'200 TEST Destination Road, Ontario, CA',packageCount:4,totalWeightKg:40,totalVolumeCbm:1,budgetAmount:100,currency:'USD',originContactName:'本机验收员',originContactPhone:'0000000000',destinationContactName:'本机验收员',destinationContactPhone:'0000000000',pickupAt:new Date().toISOString().slice(0,19),deliveryAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:marker}) + await save();let detail=await call('POST',`${trucks}?customerId=${customer.id}`,body) + report.documents[key]={customerId:customer.id,orderNo:detail.summary.orderNo};await save();return detail +} +async function truckReady(detail) { + const path=`${trucks}/${detail.summary.orderNo}`, query=`?customerId=${detail.summary.customerId}` + const step=async(suffix,body={})=>call('POST',`${path}/${suffix}${query}`,{version:detail.summary.version,idempotencyKey:randomUUID(),remark:marker,...body}) + if(detail.summary.status==='PENDING_REVIEW')detail=await step('review',{decision:'APPROVE'}) + if(detail.summary.status==='WAITING_INBOUND')detail=await step('receive-pallets',{actualPalletCount:1,packageCount:4,palletPackageCounts:[4]}) + if(detail.summary.status==='WAITING_CONSOLIDATION')detail=await step('allocate-pallets',{palletIds:detail.pallets.map(row=>row.id)}) + if(detail.summary.status==='ALLOCATED')detail=await step('prepare-dispatch') + check(detail.summary.status==='PENDING_DISPATCH','本土卡车原单审核、实收和分配后进入待派车');return detail +} +async function truckPlan(source,warehouse,key) { + if(report.documents[key])return read(report.documents[key].dispatchNo) + const body=request(key,{customerId:source.summary.customerId,orderNo:source.summary.orderNo,warehouseId:warehouse.id,plannedDepartureAt:new Date(Date.now()-300000).toISOString(),expectedDriverEndAt:new Date(Date.now()+86400000).toISOString()});await save() + const detail=await call('POST',`${base}/truck`,body);report.documents[key]={dispatchNo:detail.dispatchNo,orderNo:body.orderNo};await save() + check((await call('POST',`${base}/truck`,body)).dispatchNo===detail.dispatchNo,'本土卡车安排幂等重放');return detail +} +async function measure(source) { + const path=`/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}` + let detail=await api(pda,'GET',path) + if(detail.status==='WAIT_CUSTOMER_CONFIRMATION')return + if(!detail.pallets.length)detail=await api(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:randomUUID()}) + let pallet=detail.pallets[0] + if(pallet.status==='DRAFT')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:randomUUID()}) + pallet=detail.pallets.find(row=>row.id===pallet.id) + if(pallet.status!=='BOUND')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:randomUUID(),palletCode:pallet.palletNo}) + const boxes=detail.cartons.map(box=>({cartonMark:box.cartonMark,transferPalletId:pallet.id,length:40,width:30,height:25,weight:4,items:[{skuCode:detail.items[0].skuCode,quantity:8}]})) + if(!detail.cartons.some(row=>row.countedAt))detail=await api(pda,'POST',`${path}/cartons/count`,{idempotencyKey:randomUUID(),forecastPackageCount:detail.packageCount,boxes}) + const photo=await api(pda,'POST',`${path}/measurement-photos/files`,image('warehouse-count')) + detail=await api(pda,'POST',`${path}/palletize`,{idempotencyKey:randomUUID(),photoFileIds:[photo.fileId]}) + check(detail.status==='WAIT_CUSTOMER_CONFIRMATION'&&detail.orderStatus==='INBOUND_COMPLETED','取货到仓后仓库独立清点、测量、拍照完成入库');source.palletNo=pallet.palletNo;await save() +} +try { + const login=await api('','POST','/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD});admin=login.accessToken + // 本机旧权限目录只补正式排车资源,并授权本轮专用角色,不改既有用户或角色。 + const required=['list','create','edit','depart','cancel'].map(key=>`platform:overseas-warehouse:dispatch:${key}`) + if(!report.operatorUsername){ + const permissions=await call('GET','/api/platform/v1/iam/permissions') + const parent=permissions.find(row=>row.permissionCode===required[0]);check(parent,'存在正式排车列表权限') + for(const code of required)if(!permissions.some(row=>row.permissionCode===code))permissions.push(await call('POST','/api/platform/v1/iam/permissions',{type:'BUTTON',code,name:`排车${code.split(':').at(-1)}`,parentId:parent.id,httpMethod:'POST'})) + const role=await call('POST','/api/platform/v1/iam/roles',{roleCode:`${marker}_OPERATOR`,roleName:`${marker}本机排车验收`,description:'仅本轮本机业务验收'}) + const allowed=new Set([...login.authorities,...required]) + await call('PUT','/api/platform/v1/iam/roles/permissions',{roleId:role.id,permissionIds:permissions.filter(row=>allowed.has(row.permissionCode)).map(row=>row.id)}) + const username=`dispatch_dt_${marker.replace(/\D/g,'')}` + await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机调度验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],status:'ACTIVE'}) + report.operatorUsername=username;await save() + } + admin=(await api('','POST','/api/auth/login',{loginId:report.operatorUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken + const customers=(await call('GET','/api/platform/v1/iam/tenants?page=1&size=200')).records.filter(row=>row.status==='ACTIVE') + check(customers.length>=2,'有两个有效客户验证同仓跨客户取货') + const warehouses=(await call('GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records + const warehouse=warehouses.find(row=>row.warehouseCode==='US-LAX-01')||warehouses[0];report.warehouseId=warehouse.id + if(!report.pdaUsername){const role=(await call('GET','/api/platform/v1/iam/roles')).find(row=>row.roleCode==='OVERSEAS_WAREHOUSE_PDA_OPERATOR'&&row.status==='ACTIVE');check(role,'存在海外PDA角色');const username=`pda_dt_${marker.replace(/\D/g,'')}`;await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机排车验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],overseasWarehouseId:warehouse.id,status:'ACTIVE'});report.pdaUsername=username;await save()} + pda=(await api('','POST','/api/auth/login',{loginId:report.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken + const a=await transfer(customers[0],warehouse,'pickupA'),b=await transfer(customers[1],warehouse,'pickupB'),c=await transfer(customers[0],warehouse,'pickupCancelSource') + let shared=await pickup([a,b],'pickupCompleted'),cancelled=await pickup([c],'pickupCancelled') + const resource=await resources(warehouse);shared=await assign(shared,resource) + if(cancelled.execution.trip.status==='PLANNED'){ + await call('POST',`${base}/${cancelled.dispatchNo}/assign`,{recordVersion:cancelled.execution.trip.recordVersion,...resource,reason:marker},'A0409') + await act(cancelled,'cancel');cancelled=await read(cancelled.dispatchNo) + } + check(cancelled.execution.trip.status==='CANCELLED','取消取货排车生效') + const replanned=await pickup([c],'pickupPendingSample');check(replanned.dispatchNo!==cancelled.dispatchNo,'取货取消后可重新安排新排车,保留待操作样单') + if(shared.execution.trip.status==='PLANNED'){ + await api(pda,'POST',`/api/pda/v1/last-leg/warehouse-transfers/${a.transferNo}/pallets/drafts`,{idempotencyKey:randomUUID()},'A0409') + await call('POST',`${base}/pickup`,{...report.requests.pickupCompleted,idempotencyKey:randomUUID()},'A0409') + await act(shared,'pickup/depart');shared=await read(shared.dispatchNo) + await act(shared,'pickup/complete',{},'A0409') + } + for(const source of [a,b]){ + shared=await read(shared.dispatchNo);let task=shared.pickups.find(row=>row.transferNo===source.transferNo) + const suffix=`pickup/${source.customerId}/${source.transferNo}` + if(task.status==='ASSIGNED'){ + if(source===b&&report.pickupEvidenceA)await act(shared,`${suffix}/picked`,{actualPackages:3,evidenceFileIds:[report.pickupEvidenceA]},'A0400') + const file=await call('POST',`${base}/${shared.dispatchNo}/${suffix}/files`,image(source.transferNo)) + if(source===a){report.pickupEvidenceA=file.fileId;await save()} + const body={...command(shared),actualPackages:3,evidenceFileIds:[file.fileId],remark:marker} + await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body);await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body) + await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,{...body,actualPackages:4},'A0409') + const original=await call('GET',`${transfers}/${source.transferNo}?customerId=${source.customerId}`) + check(original.packageCount===3&&original.status==='WAIT_PDA_RECEIVING'&&JSON.stringify(original.cartons.map(row=>row.cartonMark))===JSON.stringify(source.cartonMarks),'取货事实不篡改预报箱数、箱唛及仓库收货状态') + } + shared=await read(shared.dispatchNo);task=shared.pickups.find(row=>row.transferNo===source.transferNo) + if(task.status==='PICKED')await act(shared,`${suffix}/arrive`) + } + shared=await read(shared.dispatchNo);if(shared.execution.trip.status==='IN_TRANSIT')await act(shared,'pickup/complete') + shared=await release(await read(shared.dispatchNo));check(shared.execution.trip.status==='COMPLETED','到仓取货排车完成并分别释放司机车辆') + await measure(a) + let source=await truck(customers[0],'truckOriginal') + if(!report.documents.truckCancelled)source=await truckReady(source) + let native=await truckPlan(source,warehouse,'truckCancelled') + if(native.execution.trip.status==='PLANNED'){await act(native,'cancel');native=await read(native.dispatchNo)} + source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`) + if(!report.documents.truckCompleted)check(source.summary.status==='PENDING_DISPATCH','取消本土卡车排车回退原单待派车') + native=await truckPlan(source,warehouse,'truckCompleted') + await call('POST',`${base}/truck`,{...report.requests.truckCompleted,plannedDepartureAt:new Date().toISOString()},'A0409') + if(native.execution.trip.status==='PLANNED'){ + await call('POST',`${trucks}/${source.summary.orderNo}/start-transit?customerId=${source.summary.customerId}`,{version:source.summary.version,idempotencyKey:randomUUID(),remark:marker},'A0409') + native=await assign(native,resource) + await act(native,'truck/depart',{recordVersion:native.execution.trip.recordVersion-1},'A0409') + await act(native,'truck/depart');native=await read(native.dispatchNo) + } + if(native.execution.trip.status==='IN_TRANSIT'){ + await act(native,'truck/complete',{evidenceFileIds:[]},'A0409') + const file=await call('POST',`${base}/${native.dispatchNo}/truck/files`,image('truck-completion')) + const body={...command(native),evidenceFileIds:[file.fileId]};await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body);await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body) + await call('POST',`${base}/${native.dispatchNo}/truck/complete`,{...body,evidenceFileIds:[]},'A0409') + } + native=await release(await read(native.dispatchNo)) + source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`) + check(native.execution.trip.status==='COMPLETED'&&source.summary.status==='COMPLETED'&&native.completionEvidence.length>0,'本土卡车排车完成、凭证持久化、原单同步完成') + for(const doc of [shared,native,replanned]){ + const root=await call('GET',`/api/platform/v1/last-leg/dispatches/${doc.dispatchNo}`) + check(root.taskType===doc.taskType,'同一个现有排车详情返回正确任务类型') + } + report.final={pickup:shared.dispatchNo,truck:native.dispatchNo,pending:replanned.dispatchNo,truckOriginal:source.summary.orderNo};report.passed=true;report.completedAt=new Date().toISOString();await save();console.log(JSON.stringify({passed:true,output,...report.final})) +} catch(error){report.passed=false;report.lastError=String(error);await save();console.error(String(error));process.exitCode=1} From 868557d79db3207c7ad5471a2422806710f19447 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 21:48:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/lastleg-dispatch-transport-tasks.sql | 108 ++++++++++++++++++ deploy/sql/排车运输接入-数据库变更汇总.md | 21 ++++ .../trip/FleetTransportVehicleGuard.java | 20 ++++ .../fleet/trip/TransportTripChanged.java | 5 + 4 files changed, 154 insertions(+) create mode 100644 deploy/sql/lastleg-dispatch-transport-tasks.sql create mode 100644 deploy/sql/排车运输接入-数据库变更汇总.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java diff --git a/deploy/sql/lastleg-dispatch-transport-tasks.sql b/deploy/sql/lastleg-dispatch-transport-tasks.sql new file mode 100644 index 000000000..a36d454bc --- /dev/null +++ b/deploy/sql/lastleg-dispatch-transport-tasks.sql @@ -0,0 +1,108 @@ +-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。 +-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。 +-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。 +USE baiyue_lastleg; + +SET @dispatch_transport_column_ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type' + ), + 'SELECT 1', + 'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no' +); +PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl; +EXECUTE dispatch_transport_column_stmt; +DEALLOCATE PREPARE dispatch_transport_column_stmt; + +-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。 +-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。 +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'), + 'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。 +-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。 +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY (business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY (dispatch_no), + UNIQUE KEY uk_transport_trip (trip_no), + UNIQUE KEY uk_transport_source (active_order), + KEY idx_transport_order (customer_id, order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要', + PRIMARY KEY (trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY (dispatch_no, request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; + +-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。 +SET @dispatch_transport_pool_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''' +); +PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl; +EXECUTE dispatch_transport_pool_stmt; +DEALLOCATE PREPARE dispatch_transport_pool_stmt; + +SET @dispatch_transport_pool_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'), + 'SELECT 1', + 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)' +); +PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl; +EXECUTE dispatch_transport_pool_stmt; +DEALLOCATE PREPARE dispatch_transport_pool_stmt; diff --git a/deploy/sql/排车运输接入-数据库变更汇总.md b/deploy/sql/排车运输接入-数据库变更汇总.md new file mode 100644 index 000000000..c02772b3b --- /dev/null +++ b/deploy/sql/排车运输接入-数据库变更汇总.md @@ -0,0 +1,21 @@ +# 排车运输接入数据库变更汇总 + +执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。 + +| 对象 | 新增或调整 | 用途 | +|---|---|---| +| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 | +| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 | +| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 | +| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 | +| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 | +| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 | +| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 | +| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 | +| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 | + +所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。 + +前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。 + +本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java new file mode 100644 index 000000000..6f707ec9e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 运输执行仍由车队应用边界核对车辆主档,不向排车暴露车辆持久化。 */ +@Service +public class FleetTransportVehicleGuard { + private final VehicleRepository vehicles; + /** 复用车辆应用端口及其行锁。 */ + public FleetTransportVehicleGuard(VehicleRepository vehicles){this.vehicles=vehicles;} + /** 发车核对实时车辆资格,并冻结当次有效容量供业务量复核。 */ + public void validate(FleetTripEntity trip){ + var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"车辆不存在")); + if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw new ResponseStatusException(HttpStatus.CONFLICT,"当前车辆不可出车"); + trip.setMaxPalletSpaces(vehicle.maxPalletSpaces());trip.setMaxLoadKg(vehicle.maxLoadKg());trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java new file mode 100644 index 000000000..fb395ffac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.fleet.trip; + + +/** 同步事务内的运输事实;任一投影失败时原执行一起回滚。 */ +public record TransportTripChanged(FleetTripEntity trip,String action,String reason,String operator) { }