From c38f1758c5c4ea105210f2a95d6888f0a29fed2b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 10:56:37 +0800 Subject: [PATCH 1/5] fix(firstleg): remove booking document prerequisites from release and trucking --- firstleg/docs/business-index.md | 4 +- .../firstleg/dto/TruckAppointmentDtos.java | 3 +- .../mapper/TruckAppointmentMapper.java | 7 ++-- .../service/OceanBookingService.java | 3 +- .../service/TruckAppointmentService.java | 6 +-- .../resources/openapi/ocean-bookings-v1.yaml | 4 +- .../openapi/truck-appointments-v1.yaml | 10 ++--- ...ruckAppointmentControllerContractTest.java | 2 +- .../service/OceanBookingServiceTest.java | 21 +++++----- .../TruckAppointmentMapperContractTest.java | 8 ++-- .../service/TruckAppointmentServiceTest.java | 39 ++++++++----------- 11 files changed, 50 insertions(+), 57 deletions(-) diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index 53f3cf7a5..3b135057c 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -58,7 +58,7 @@ | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | | 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 | -| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | +| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单号或已有非终态预约的订舱;创建及派车不要求订舱文件;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | @@ -79,7 +79,7 @@ - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 -- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 +- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。 - 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index de0dac98f..1b98c9ed2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -3,7 +3,8 @@ import jakarta.validation.constraints.*; import java.math.BigDecimal; import jav public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}} - public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){} + /** 已放舱订舱候选,预约不依赖订舱附件。 */ + public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName){} public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 7d0283513..b0b45c7e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,15 +1,14 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ + /** 查询已放舱且有提单号的可预约订舱,不依赖附件。 */ @Select(""" ") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List confirmedPlan(@Param("customer") String customer,@Param("id") long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java index f32b74661..3c2381991 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java @@ -28,8 +28,8 @@ public class DispatchPickingRepository { public int pick(DispatchPdaPick pick){return picks.insert(pick);} /** 本明细实际已扫码数量。 */ public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);} - /** 自然收货后保存已经核实的同一计划来源实托身份。 */ - public int materialize(DispatchItem item){return items.updateById(item);} + /** 自然收货后保存同一来源实托、箱数和货量,允许未知货量清除计划估算。 */ + public int materialize(DispatchItem item){return items.confirmReceipt(item);} /** 按真实客户取本车扫码,不借主单客户头。 */ public List picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java index d07d57d93..3970a395d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -75,6 +75,9 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate for(var entry:received.entrySet()) { entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId()); entry.getKey().setPackageCount(entry.getValue().packageCount()); + // 原预排的均分重量体积在确认入库时一并交接;未知值也必须覆盖旧计划快照。 + entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg()); + entry.getKey().setVolumeCbm(entry.getValue().volumeCbm()); } for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java index a63efb2d8..d7b72f197 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -10,10 +10,11 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class DispatchPhysicalWeightTest { + /** 计划估算不能用材积重补毛重,混合来源缺失时保持未知。 */ @Test void billingVolumeWeightCannotFillMissingPhysicalWeight() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))"); - jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); + currentStockSchema(jdbc); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); var tested = new java.util.HashSet(); @@ -30,15 +31,17 @@ class DispatchPhysicalWeightTest { assertThat(jdbc.queryForObject("SELECT " + projection + " FROM (SELECT 80 gross_weight_kg) wtp", BigDecimal.class)).isEqualByComparingTo("80"); continue; } - String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip " - + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l " - + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a " - + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o"; + String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count) l " + + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm,10 AS counted_package_count) a " + + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o " + + "CROSS JOIN (SELECT CAST(NULL AS BIGINT) AS warehouse_pallet_id,'RESERVED' AS assignment_status,0 AS actual_package_count) p " + + "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,ip.customer_id,ip.source_order_id"; assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull(); if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet. jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)"); assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20"); - String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10"); + String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count", "SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count UNION ALL SELECT 2,10,20"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull(); jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); @@ -47,4 +50,128 @@ class DispatchPhysicalWeightTest { } assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); } + + /** 执行正式候选投影,21箱两托计划可均分,实收10+11后必须切换到对应货量。 */ + @Test void receivedCandidatesUseTenAndElevenCartonsInsteadOfEqualPlanShares() { + var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + currentStockSchema(jdbc); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); + jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,570.5),('other',1,9999)"); + jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',570.5,21,3.528)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0)"); + var tested=new java.util.HashSet(); + for(var method:DispatchCandidateMapper.class.getDeclaredMethods()) { + if(!java.util.Set.of("available","wholeAvailable","readSelected","lockSelected").contains(method.getName()))continue; + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replace(">",">").replace("<","<"); + String weight=sql.substring(sql.indexOf("AS volumeWeightKg,")+"AS volumeWeightKg,".length(),sql.indexOf(" AS grossWeightKg,")); + String volume=sql.substring(sql.indexOf("AS packageCount,")+"AS packageCount,".length(),sql.indexOf(" AS volumeCbm,")); + String from=" FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,3.528 AS volume_cbm,21 AS required_package_count) l " + + "CROSS JOIN (SELECT 1 AS line_id,1.764 AS allocated_volume_cbm,10 AS counted_package_count,1.680 AS actual_volume_cbm) a " + + "CROSS JOIN (SELECT 501 AS warehouse_pallet_id,'RECEIVING' AS assignment_status,10 AS actual_package_count,1.764 AS allocated_volume_cbm) p " + + "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,p.allocated_volume_cbm,ip.customer_id,ip.source_order_id"; + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).as(method.getName()).isEqualByComparingTo("271.667"); + assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isEqualByComparingTo("1.680"); + String second=from.replace("10 AS counted_package_count,1.680 AS actual_volume_cbm","11 AS counted_package_count,1.848 AS actual_volume_cbm"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=11"); + assertThat(jdbc.queryForObject("SELECT "+weight+second,BigDecimal.class)).isEqualByComparingTo("298.833"); + assertThat(jdbc.queryForObject("SELECT "+volume+second,BigDecimal.class)).isEqualByComparingTo("1.848"); + String plan=from.replace("'RECEIVING' AS assignment_status,10 AS actual_package_count","'RESERVED' AS assignment_status,0 AS actual_package_count"); + assertThat(jdbc.queryForObject("SELECT "+weight+plan,BigDecimal.class)).isEqualByComparingTo("285.25"); + assertThat(jdbc.queryForObject("SELECT "+volume+plan,BigDecimal.class)).isEqualByComparingTo("1.764"); + jdbc.update("UPDATE ll_inbound_order SET package_count=0"); + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull(); + jdbc.update("UPDATE ll_inbound_order SET package_count=21,volume_cbm=0"); + assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isNull(); + jdbc.update("UPDATE ll_inbound_order SET volume_cbm=3.528"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',2,501,'9',1,0,0,0,0)"); + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull(); + jdbc.update("DELETE FROM ll_inventory_position WHERE id=2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10"); + tested.add(method.getName()); + } + assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); + } + + /** 拆出、部分出库及合入其他来源后,按当前来源组成重算;同客户同箱数也不能复用旧货量。 */ + @Test void currentStockMeasurementsFollowSplitPartialShipmentAndMergedSources() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',200,20,4),(2,'10',300,10,3),(3,'other',9000,10,9)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1),(2,'10',2),(3,'other',3)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(3,'other',3,501,'9',10,0,0,0,0)"); + assertMetrics(jdbc,501,"100","2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',1,502,'9',5,0,0,0,0)"); + assertMetrics(jdbc,501,"50","1");assertMetrics(jdbc,502,"50","1"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=2,picked_packages=3 WHERE id=1"); + assertMetrics(jdbc,501,"20","0.4"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(4,'10',2,501,'9',8,0,0,0,0)"); + assertMetrics(jdbc,501,"260","2.8"); + // 任一有效库存来源无法解析时不能只返回其他来源的部分合计。 + jdbc.update("UPDATE ll_inventory_balance SET inbound_order_id=999 WHERE id=2"); + assertMetrics(jdbc,501,null,null); + } + + /** 尾程订单9001与入仓快照7001不同,仍按库存真实关联读取各托实测,而不是均摊总重量。 */ + @Test void transferMeasurementsUseLinkedInboundAndPreserveDifferentPalletWeights() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(9001,'other',9000,20,9)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(2,'10',1,502,'9',10,0,0,0,0)"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')"); + jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1),(80,'10',502,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,200,2)"); + assertMetrics(jdbc,501,"100","1");assertMetrics(jdbc,502,"200","2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1"); + assertMetrics(jdbc,501,"75","0.75"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10 WHERE id=1"); + jdbc.update("INSERT INTO ll_inventory_ledger VALUES(501,TIMESTAMP '2026-10-23 02:00:00','PALLET_DISASSEMBLE')"); + assertMetrics(jdbc,501,"150","1.5"); + } + + /** 同托混入其他来源或冻结货物后,旧整托测量不能冒充当前可用份额;未知单项分别保留。 */ + @Test void changedCompositionAndMissingMetricsCannotReuseWholePalletMeasurements() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(7002,'10',400,20,4)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001),(2,'10',7002)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',5,0,0,0,0),(2,'10',2,501,'9',5,0,0,0,0)"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')"); + jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1)"); + assertMetrics(jdbc,501,"175","1.75"); + jdbc.update("DELETE FROM ll_inventory_position WHERE id=2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10,hold_packages=5"); + assertMetrics(jdbc,501,"150","1.5"); + jdbc.update("UPDATE ll_inbound_order SET gross_weight_kg=NULL WHERE id=7001"); + assertMetrics(jdbc,501,null,"1.5"); + jdbc.update("UPDATE ll_inbound_order SET package_count=0 WHERE id=7001"); + assertMetrics(jdbc,501,null,null); + } + + /** 每个场景独享内存数据,执行生产SQL而不连接共享环境。 */ + private static JdbcTemplate measurementDatabase() { + var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + currentStockSchema(jdbc);return jdbc; + } + + /** 直接执行候选共用的生产货量表达式,分别检查未知值和精度。 */ + private static void assertMetrics(JdbcTemplate jdbc,long pallet,String weight,String volume) { + String sql=("SELECT "+DispatchCandidateMapper.CURRENT_GROSS_WEIGHT+" AS weight,"+DispatchCandidateMapper.CURRENT_VOLUME+" AS volume " + +"FROM (SELECT '10' AS customer_id,9001 AS source_order_id) ip CROSS JOIN (SELECT CAST(? AS BIGINT) AS warehouse_pallet_id) p") + .replace(">",">").replace("<","<"); + var values=jdbc.queryForObject(sql,(rs,n)->new BigDecimal[]{rs.getBigDecimal("weight"),rs.getBigDecimal("volume")},pallet); + if(weight==null)assertThat(values[0]).isNull();else assertThat(values[0]).isEqualByComparingTo(weight); + if(volume==null)assertThat(values[1]).isNull();else assertThat(values[1]).isEqualByComparingTo(volume); + } + + /** 与生产库存来源及转运实测相同的最小表结构,不借旧计划数量充当当前库存。 */ + private static void currentStockSchema(JdbcTemplate jdbc) { + jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3),package_count INT,volume_cbm DECIMAL(18,4))"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR,available_packages INT,hold_packages INT,allocated_packages INT,picked_packages INT,receiving_packages INT)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,inbound_order_id BIGINT,operating_warehouse_id VARCHAR)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,status VARCHAR,completed_at TIMESTAMP,carton_count INT,gross_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4))"); + jdbc.execute("CREATE TABLE ll_inventory_ledger(pallet_id BIGINT,created_at TIMESTAMP,business_type VARCHAR)"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 2f48967cc..412c304be 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.test.util.ReflectionTestUtils; import java.time.*; +import java.math.BigDecimal; import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -23,30 +24,66 @@ import static org.mockito.Mockito.*; /** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ class DispatchInboundReceiptPersistenceTest { - /** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ + /** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var db=new org.springframework.jdbc.core.JdbcTemplate(ds); db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)"); - db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); - db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR,order_id BIGINT,required_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT,actual_volume_cbm DECIMAL(18,4))"); + db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3))"); + db.update("INSERT INTO ll_order_detail VALUES('101',9001,570.5),('other',9001,9999)"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')"); - db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8',9001,20)"); for(int n=1;n<=2;n++) { db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n); - db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?,?)",n,n==1?9:11,new BigDecimal(n==1?"1.5876":"1.9404")); } var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11); + assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isEqualByComparingTo("256.725"); + assertThat(mapper.confirmedPlan("101",2).get(0).grossWeightKg()).isEqualByComparingTo("313.775"); + assertThat(mapper.confirmedPlan("101",1).get(0).volumeCbm()).isEqualByComparingTo("1.5876"); + assertThat(mapper.confirmedPlan("101",2).get(0).volumeCbm()).isEqualByComparingTo("1.9404"); + db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); + assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } + /** 原排车重量体积随实收交接持久化,重复确认不覆盖已经交接的快照。 */ + @Test void receiptReplacesEqualPlannedMeasurementsWithUnequalReceivedMeasurements() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764"); + f.p.f.candidates.get(1L).grossWeightKg(new BigDecimal("256.725")); + f.p.f.candidates.get(1L).volumeCbm(new BigDecimal("1.5876")); + f.p.f.candidates.get(2L).grossWeightKg(new BigDecimal("313.775")); + f.p.f.candidates.get(2L).volumeCbm(new BigDecimal("1.9404")); + f.confirm();f.confirm(); + var items=f.p.f.repository.cargo(f.p.f.repository.find(f.p.number).getId()); + items=items.stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchItem::getPlanPalletId)).toList(); + assertThat(items.get(0).getGrossWeightKg()).isEqualByComparingTo("256.725"); + assertThat(items.get(1).getGrossWeightKg()).isEqualByComparingTo("313.775"); + assertThat(items.get(0).getVolumeCbm()).isEqualByComparingTo("1.5876"); + assertThat(items.get(1).getVolumeCbm()).isEqualByComparingTo("1.9404"); + assertThat(f.quantities()).containsExactly(9,11); + } + + /** 收货来源缺失货量时显式清空旧估算,不能因MyBatis忽略NULL而显示过期计划值。 */ + @Test void missingReceivedMeasurementsClearPlannedValues() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764"); + f.p.f.candidates.values().forEach(c->{c.grossWeightKg(null);c.volumeCbm(null);}); + f.confirm(); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE gross_weight_kg IS NOT NULL OR volume_cbm IS NOT NULL",Integer.class)).isZero(); + } + /** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */ @Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception { var f=new Fixture();f.confirm(); @@ -103,6 +140,7 @@ class DispatchInboundReceiptPersistenceTest { var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId(); f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'"); f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_order SET destination_type='PRIVATE' WHERE id=9001"); f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch); f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE")); f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); @@ -149,6 +187,12 @@ class DispatchInboundReceiptPersistenceTest { p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'"); p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0"); p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'"); + // 审核按当前库存及已拣归属核平;补齐实托、库存账和订单之间的真实读取关系。 + p.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT)"); + p.f.db.execute("ALTER TABLE ll_order ADD (inbound_code VARCHAR(128),destination_type VARCHAR(20),fba_warehouse_code VARCHAR(128))"); + p.f.db.update("UPDATE ll_order SET inbound_code='IN-1',destination_type='FBA',fba_warehouse_code='ONT8' WHERE id=9001"); + p.f.db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(21,'101','IN-1'),(22,'101','IN-1')"); + p.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,'101',501),(2,'101',502)"); p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)"); p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8"); p.f.candidates.get(2L).destinationWarehouseCode("ONT8"); From 7ef8411ea45ac9c57e43f420da3b5c605474d3bd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 12:42:20 +0800 Subject: [PATCH 5/5] fix(dispatch): publish capacity demands only during creation --- deploy/sql/海外区域统一增量-MySQL8.sql | 2 +- firstleg/docs/business-index.md | 2 +- lastleg-service/AGENTS.md | 2 +- .../dispatch/entity/DispatchAggregate.java | 2 +- .../dispatch/port/DispatchCommands.java | 8 ++--- .../service/DispatchAggregateService.java | 2 +- .../resources/db/dispatch-capacity-demand.sql | 2 +- .../src/main/resources/db/lastleg.sql | 2 +- .../openapi/overseas-business-v1.yaml | 11 ++----- .../PlatformDispatchOutboundContractTest.java | 2 +- .../port/DispatchPortCompileContract.java | 1 + .../port/DispatchPublicJsonContract.java | 4 ++- .../DispatchAggregatePersistenceTest.java | 4 +-- ...DispatchCapacityDemandPersistenceTest.java | 31 ++++++++++++++----- ...DestinationAppointmentPersistenceTest.java | 6 ++-- .../DispatchFeedbackRegressionTest.java | 4 +-- ...DispatchListAndResourceRegressionTest.java | 4 +-- .../DispatchMaintenancePersistenceTest.java | 10 +++--- ...DispatchResourcePhotosPersistenceTest.java | 2 +- ...DispatchReviewWorkflowPersistenceTest.java | 2 +- ...mentWorkPreArrangementPersistenceTest.java | 2 ++ ...ppointmentWorkDispatchPersistenceTest.java | 19 +++++++++--- .../fleet-trip-fulfillment-contract.test.mjs | 2 +- .../fleet-trip-fulfillment.mjs | 1 - 24 files changed, 75 insertions(+), 52 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c9c1fe2a0..14527036f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -417,7 +417,7 @@ 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 '任务类型:仓库派送、到仓取货、本土卡车', - publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位', + publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index ae30176d1..e9b8b8f95 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -42,7 +42,7 @@ | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | -| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | +| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联允许真实集装箱号暂缺;确认预装和创建 PDA task 前必须补齐非空柜号,不按 ISO 格式阻断;可在 `EDITING/LOADING` 首次补录,PDA 完成前再次校验;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 155910180..e56fbceba 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -56,7 +56,7 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | -| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 | +| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | | 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | 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 337013c1d..c64690d74 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 @@ -10,7 +10,7 @@ public class DispatchAggregate { private String dispatchNo; private String taskType; /** 是否向内部散板需求池发布本车余位。 */ - private Boolean publishLooseboardDemand = true; + private Boolean publishLooseboardDemand = false; private Long tripId; private String overseasWarehouseId; private String overseasWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 431941f9b..354674e64 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -24,15 +24,15 @@ public final class DispatchCommands { public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, List cargo, List batchAllocations, Boolean publishLooseboardDemand) { - /** 新建排车默认发布内部余位,显式取消时保持不发布。 */ - public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; } + /** 仅智能排车建单时显式选择发布,未选择时不发布。 */ + public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); } } public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, - String remark, Boolean publishLooseboardDemand, Maintenance maintenance) { + String remark, Maintenance maintenance) { /** 缺版本与有效版本零必须区分。 */ - public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); } + public Update { recordVersion = requireVersion(recordVersion); } } public record AddCargo(Long recordVersion, String idempotencyKey, List cargo, 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 5b6b233c2..2380891d3 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 @@ -451,7 +451,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); - row.setPublishLooseboardDemand(request.publishLooseboardDemand()); + // 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。 if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); return saveCommand(row,actor,identity,request.remark()); diff --git a/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql b/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql index 6a5f798cc..c927c9961 100644 --- a/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql +++ b/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql @@ -1,3 +1,3 @@ -- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。 ALTER TABLE ll_dispatch_order - ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type; + ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位' AFTER task_type; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 72a1c4fa0..f5e6e42a4 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1895,7 +1895,7 @@ 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 '任务类型:仓库派送、到仓取货、本土卡车', - publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位', + publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 15dba995d..54830a9ff 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4080,10 +4080,6 @@ components: type: object x-java-record: DispatchCommands.Update properties: - publishLooseboardDemand: - type: boolean - description: 是否向内部散板需求池发布本车余位;新建默认勾选。 - recordVersion: oneOf: - type: string @@ -4118,7 +4114,6 @@ components: nullable: true description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - - publishLooseboardDemand - recordVersion - idempotencyKey DeliveryStatusChange: @@ -5984,8 +5979,8 @@ components: properties: publishLooseboardDemand: type: boolean - description: 是否向内部散板需求池发布本车余位;新建默认勾选。 - default: true + description: 是否向内部散板需求池发布本车余位;仅智能排车建单时选择,默认不发布。 + default: false idempotencyKey: type: string @@ -6701,7 +6696,7 @@ components: properties: publishLooseboardDemand: type: boolean - description: 是否向内部散板需求池发布本车余位;新建默认勾选。 + description: 建单时确定的散板需求发布选择,维护车辆不可修改。 dispatchNo: type: string diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index ad135437d..00a42c809 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest { assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) .noneMatch(method -> Set.of("depart", "cancel").contains(method.getName()))); assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver", - "plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand", "maintenance"), + "plannedDepartureAt", "estimatedArrivalAt", "remark", "maintenance"), Arrays.stream(DispatchCommands.Update.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java index b70efee54..cb772ce62 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java @@ -36,6 +36,7 @@ public final class DispatchPortCompileContract { } catch (IllegalArgumentException expected) { require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本"); } + require(!fields(DispatchCommands.Update.class).contains("publishLooseboardDemand"), "发布选择仅在创建时确定,维护车辆不得修改"); require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位"); require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位"); require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java index 017833f86..0cab9300c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java @@ -39,12 +39,14 @@ public final class DispatchPublicJsonContract { rejectsMissing(requests, request, "{}"); rejectsMissing(requests, request, "{\"recordVersion\":null}"); rejectsMissing(requests, request, "{\"recordVersion\":-1}"); - Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request); + Object zero = requests.readValue("{\"recordVersion\":0}", request); require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法"); } rejectsMissing(requests, DispatchCommands.Update.class, "{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}"); + rejectsMissing(requests, DispatchCommands.Update.class, "{\"recordVersion\":0,\"publishLooseboardDemand\":true}"); + Instant now = Instant.parse("2026-10-19T00:00:00Z"); var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1); var destination = new Destination("FBA:ONT8", "FBA", "1", "ONT8", "目的仓", null, null, null, null); 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 5649869b0..a7d8a6281 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 @@ -249,7 +249,7 @@ public class DispatchAggregatePersistenceTest { /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); - var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); @@ -289,7 +289,7 @@ public class DispatchAggregatePersistenceTest { } @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); - var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java index a8ec56d61..fd42e7830 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java @@ -9,22 +9,37 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class DispatchCapacityDemandPersistenceTest { - /** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */ - @Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception { + /** 未选择发布的新排车保持私有,维护与重放均不能开启发布。 */ + @Test void defaultsToUnpublishedAndMaintenanceCannotEnablePublication() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class); var created=f.tx.execute(s->f.service.create(f.actor,request)); var service=new DispatchCapacityDemandService(f.repository,f.json); - assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); - assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); - var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null); - var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse(); - assertThat(service.page(query()).total()).isZero(); + var update=new DispatchCommands.Update(created.recordVersion(),"pool-maintain-private",null,null,null,null,"补充资料",null); + var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion()); + assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse(); assertThat(service.page(query()).total()).isZero(); } + /** 建单发布后维护不改变发布;重发须取消原排车,再建新单。 */ + @Test void preservesPublicationUntilCancellationAndRepublishesOnlyOnNewDispatch() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-publish",List.of()))); + var service=new DispatchCapacityDemandService(f.repository,f.json); + var update=new DispatchCommands.Update(created.recordVersion(),"pool-maintain-public",null,null,null,null,"补充资料",null); + var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); + var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); + assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion()); + assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); + assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); + f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),new DispatchCommands.ReasonCommand(saved.recordVersion(),"pool-cancel","取消后重新创建"))); + assertThat(service.page(query()).total()).isZero(); + var recreated=f.tx.execute(s->f.service.create(f.actor,f.create("pool-republish",List.of()))); + assertThat(recreated.dispatchNo()).isNotEqualTo(created.dispatchNo()); + assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(recreated.dispatchNo()); + } /** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */ @Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); @@ -63,7 +78,7 @@ class DispatchCapacityDemandPersistenceTest { f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load)); assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero(); } - /** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */ + /** 新建时显式不发布,查询只按真实仓库与路线筛选。 */ @Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of()); var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentPersistenceTest.java index 4e79ded88..a3a449cfc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentPersistenceTest.java @@ -14,7 +14,7 @@ class DispatchDestinationAppointmentPersistenceTest { var created=f.tx.execute(t->f.service.create(f.actor,input)); assertThat(f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments()).hasSize(1); assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"missing",null)))).hasMessageContaining("ONT8"); - var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE")))))); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE")))))); var loaded=f.approve(saved);assertThat(loaded.stage()).isEqualTo(Stage.LOADING); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())); assertThat(detail.dispatchInfo().fbaAppointments()).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE")); @@ -23,9 +23,9 @@ class DispatchDestinationAppointmentPersistenceTest { @Test void savingOneOfTwoNumbersIsAllowedButReviewNeedsBoth() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("two-dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LGB8",5))); var created=f.tx.execute(t->f.service.create(f.actor,input)); - var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE")))))); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE")))))); assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"second-missing",null)))).hasMessageContaining("LGB8"); - var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO")))))); + var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO")))))); assertThat(f.approve(complete).stage()).isEqualTo(Stage.LOADING); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java index 62ee3a748..a950ac317 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java @@ -33,7 +33,7 @@ class DispatchFeedbackRegressionTest { var input=f.configured("update",List.of()); var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(), new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))), - input.plannedDepartureAt(),null,null,true,null); + input.plannedDepartureAt(),null,null,null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); f.prepareAppointmentNumbers(created.dispatchNo()); var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence(); @@ -78,7 +78,7 @@ class DispatchFeedbackRegressionTest { var update=new DispatchCommands.Update(created.recordVersion(),"save-draft", new DispatchCommands.Vehicle(v.mode(),null,v.plate(),v.carrierName(),v.capacity(),List.of()), new DispatchCommands.Driver(d.mode(),null,d.name(),d.phone(),new DispatchContracts.DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),List.of(),e.expectedTaskEndAt(),List.of())), - input.plannedDepartureAt(),null,null,true,null); + input.plannedDepartureAt(),null,null,null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); f.prepareAppointmentNumbers(created.dispatchNo()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java index fac569244..371b2fa74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java @@ -66,13 +66,13 @@ class DispatchListAndResourceRegressionTest { when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver); var input=f.configured("input",List.of()); var requestDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))); - var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,true,null))); + var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,null))); var snapshot=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver();var proof=snapshot.evidence(); when(driver.licenseFileId()).thenReturn("82"); var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");clearInvocations(evidence); doThrow(new IllegalArgumentException("旧主档证照不能按本车附件读取")).when(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L)); var changedDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,snapshot.name(),snapshot.phone(),new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),91L)); - f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,true,null))); + f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,null))); var saved=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence(); assertThat(saved.fileIds()).containsExactly(82L,91L);assertThat(saved.masterLicenseFileId()).isEqualTo(82L); verify(evidence,never()).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java index e434ee1a2..fbff2aeca 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java @@ -31,7 +31,7 @@ class DispatchMaintenancePersistenceTest { var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装", " fleet@example.test ", "厢式货车", " account ", List.of(new com.baiyue.lastleg.dispatch.port.DispatchContracts.DestinationAppointment("FBA:ONT8", " appointment "))); var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(), - input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance); + input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", maintenance); var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)); assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue(); f.approve(saved); @@ -63,7 +63,7 @@ class DispatchMaintenancePersistenceTest { var dispatchId = f.repository.find(created.dispatchNo()).getId(); doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L); var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)), - input.driver(), input.plannedDepartureAt(), null, null, true, null); + input.driver(), input.plannedDepartureAt(), null, null, null); assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason); assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero(); @@ -76,9 +76,9 @@ class DispatchMaintenancePersistenceTest { var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"destinationAppointments\":[]}", Maintenance.class); assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null)); var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( - created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty))); + created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, empty))); f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( - saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null))); + saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, null))); var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); assertThat(detail.vehicleInfo().maintenance()).isNull(); assertThat(detail.dispatchInfo().departedAt()).isNull(); @@ -112,7 +112,7 @@ class DispatchMaintenancePersistenceTest { var created = f.tx.execute(t -> f.service.create(f.actor, input)); var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, List.of()); f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(), - "projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance))); + "projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, maintenance))); var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository, mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class), mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java index 464afa4b9..d738946d2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest { var e = d.evidence(); var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); - return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", null); } /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 1bbebc400..8bd3e1731 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest { long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); var resources=f.configured("updated-resources",List.of()); - var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null))); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", null))); var detail=f.service.detail(f.actor,created.dispatchNo()); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java index 448c8e9f9..ef54bdc3d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java @@ -35,6 +35,8 @@ class AppointmentWorkPreArrangementPersistenceTest { assertEquals("FAILED",batch.appointmentStatus());var originalB=store.plan(batch.batchKey(),"B"); var session=context.getBean(SqlSessionTemplate.class);session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class); var mapper=spy(session.getMapper(AppointmentWorkDispatchMapper.class)); + // 本场景尚未扫码;保持真实已拣为空,不能借预排数量伪造出库事实。 + doReturn(List.of()).when(mapper).pickedPhysical(anyLong(),anyString()); doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id()); doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B"); doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index b02143dc9..3f11ca6af 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -59,7 +59,13 @@ class AppointmentWorkDispatchPersistenceTest { var ds=new DriverManagerDataSource("jdbc:h2:mem:appointment-dispatch-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))"); - jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT,destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))"); + // 已收货来源读取库存余额,已拣来源独立读取扫码归属,不能退回计划数量代替。 + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(40),pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT)"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(40),inbound_code VARCHAR(128))"); + jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),inbound_code VARCHAR(128),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40),order_no VARCHAR(40))"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(40),dispatch_item_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(pick_id BIGINT,customer_id VARCHAR(40),order_id BIGINT,packages INT)"); jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)"); @@ -97,7 +103,11 @@ class AppointmentWorkDispatchPersistenceTest { } /** 插入真实客户、托盘和逐票数量,不从请求猜测。 */ private void seed(long item,String customer,long order,int count,String destination) { - jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?,?,'ONT8')",item,customer,item+100,count,destination); + jdbc.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,0,0)",item,customer,item+1000,item,count); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(?,?,?)",item,customer,"IN-"+order); + if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=?",Integer.class,order)==0) + jdbc.update("INSERT INTO ll_order VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order); jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); @@ -163,7 +173,7 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.update("UPDATE ll_inbound_fba_appointment SET version=3 WHERE id=1");assertEquals(3L,binding.candidateBatches(inputs).get(inputs.get(0)).appointmentBatches().get(0).recordVersion()); } @Test void twoDispatchesCannotConsumeTheSameBatchQuotaConcurrently() throws Exception { - jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80)"); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80,'FBA','ONT8')"); var start=new CyclicBarrier(2);var pool=Executors.newFixedThreadPool(2); try { var results=new ArrayList>(); @@ -175,6 +185,7 @@ class AppointmentWorkDispatchPersistenceTest { @Test void lateConfirmedAppointmentCanBindBothExistingPalletsWithoutReplacingCargo() { jdbc.update("UPDATE ll_dispatch_item SET package_count=40 WHERE id=11"); jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=40,counted_package_count=40 WHERE pallet_id=111"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=40 WHERE id=11"); seed(12,"101",1,40,"FBA"); var original=jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"); tx.executeWithoutResult(s->bind(11,12)); @@ -201,8 +212,6 @@ class AppointmentWorkDispatchPersistenceTest { } /** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */ @Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() { - jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),order_no VARCHAR(40),inbound_code VARCHAR(40))"); - jdbc.update("INSERT INTO ll_order VALUES(1,'101','WC-1','IN-1'),(2,'202','WC-2','IN-2'),(3,'303','WC-3','IN-3')"); tx.executeWithoutResult(s->bind(11)); var identity=new CandidatePhysical("101",111L); var option=binding.allocationOptions(1,List.of(identity)).get(identity).get(0); diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 47821a734..7efdfbe4d 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -178,7 +178,7 @@ function server(fault = '') { pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] }) } if (method === 'PUT' && path === D) { - assert.equal(body.publishLooseboardDemand, detail.publishLooseboardDemand) + assert.equal(Object.hasOwn(body, 'publishLooseboardDemand'), false) if (detail.stage !== 'BEFORE_LOADING') { if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount-- return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派') diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 9eed3287f..8f2c05570 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -175,7 +175,6 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) maintenance.destinationAppointments = [...appointments.values()] proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), maintenance, - publishLooseboardDemand: before.publishLooseboardDemand, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] }, driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt, fileIds: [proof.reviewPhotos.DRIVER.fileId], photoFileIds: [proof.reviewPhotos.DRIVER_PHOTO.fileId] } },