diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index ddb6985c6..0c0184061 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -61,6 +61,15 @@ public class PlatformFinanceController { @GetMapping("/fee-pool") @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") @Operation(summary = "客户应收应付费用池") + public ApiResponse feePool( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String direction, + @RequestParam(required = false) Long supplierId) { + return FinanceResponses.success(queryService.feePool(customerId, direction, supplierId)); + } + + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") public ApiResponse feePool( @Parameter(description = "可选客户筛选;省略时查询全部客户") @RequestParam(required = false) Long customerId, diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 08b345067..807dff4d9 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -118,7 +118,8 @@ public class FinancePhase1OperationService { throw error("FIN-REQ-400", "开始日期不能晚于结束日期"); } String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效"); - String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效"); + String normalizedReview = normalized(reviewStatus, + List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); String normalizedSourceStatus = normalized(sourceStatus, List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)"; diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index db848ed88..1997a8924 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -99,7 +99,12 @@ public class FinanceQueryService { } public List feePool(Long customerId, String direction) { + return feePool(customerId, direction, null); + } + + public List feePool(Long customerId, String direction, Long supplierId) { validateOptionalCustomer(customerId); + if (supplierId != null && supplierId <= 0) throw bad("供应商范围无效"); String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE"); List result = new ArrayList<>(); @@ -107,6 +112,14 @@ public class FinanceQueryService { List sourceArgs = new ArrayList<>(); if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); } if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); } + if ("PAYABLE".equals(normalized)) { + sourceSql += " AND review_status='APPROVED' AND settlement_mode='ON_ACCOUNT' AND currency_code='CNY' AND amount>0"; + sourceSql += " AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id WHERE sl.fee_source_id=fin_fee_source.id AND s.status<>'VOIDED')"; + if (supplierId != null) { + sourceSql += " AND CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?"; + sourceArgs.add(supplierId); + } + } sourceSql += " ORDER BY created_at DESC,id DESC"; result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> { String rowDirection = rs.getString("direction"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java index 6f18725ab..8806fff05 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java @@ -39,14 +39,35 @@ class FinanceFeePoolDirectionBehaviorTest { assertThat(jdbc.costQueryCount).isZero(); } + @Test + void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() { + DirectionJdbc jdbc = new DirectionJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(7L, "PAYABLE", 81L); + + assertThat(jdbc.sourceSql).contains( + "review_status='APPROVED'", + "settlement_mode='ON_ACCOUNT'", + "currency_code='CNY'", + "NOT EXISTS (SELECT 1 FROM fin_settlement_line", + "s.status<>'VOIDED'", + "CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?"); + assertThat(jdbc.sourceArgs).containsExactly(7L, "PAYABLE", 81L); + } + private static final class DirectionJdbc extends JdbcTemplate { private final List sourceDirections = new ArrayList<>(); private int costQueryCount; + private String sourceSql; + private List sourceArgs = List.of(); @Override @SuppressWarnings("unchecked") public List query(String sql, RowMapper mapper, Object... args) { if (sql.contains("FROM fin_fee_source")) { + sourceSql = sql; + sourceArgs = List.of(args); String direction = String.valueOf(args[1]); sourceDirections.add(direction); return (List) List.of(item(direction, "FIRSTLEG_FEE", 1L)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 59922f76e..1279f8a48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; @@ -69,6 +70,8 @@ public class ContainerBookingEntity { @TableField(exist = false) private Long planId; @TableField(exist = false) private String planNo; @TableField(exist = false) private String planStatus; + /** Actual completion timestamp projected from the associated loading plan. */ + @JsonIgnore @TableField(exist = false) private LocalDateTime completedLoadingAt; @TableField(exist = false) private Boolean exceptionActive; @TableField(exist = false) private String destinationClearanceDocumentStatus; @TableField(exist = false) private String customsDeclarationDocumentStatus; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index 61f5b0ae3..44699456d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -42,7 +42,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no, + CASE WHEN p.plan_status = 'COMPLETED' THEN COALESCE(p.confirmed_at, p.updated_at) END AS completed_loading_at, CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK' THEN TRUE ELSE FALSE END AS exception_active, @@ -77,6 +78,7 @@ public interface ContainerBookingMapper extends BaseMapper page(Query query) { - return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query)); + Page result = mapper.selectBookingPage( + new Page<>(query.page(), query.size()), query); + result.getRecords().forEach(this::resolveLoadingTime); + return PageResult.from(result); } - public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); } + public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); } public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); } public boolean containerNoExists(String no, Long excludedId) { @@ -56,4 +59,12 @@ public class ContainerRepository { return mapper.syncReleasedTransport(oceanBookingId, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, operatorId, operatorName); } + + private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) { + if (value != null && value.getLoadingAt() == null + && "COMPLETED".equals(value.getPlanStatus())) { + value.setLoadingAt(value.getCompletedLoadingAt()); + } + return value; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index db62714e6..d2cdc3d62 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -244,7 +244,8 @@ public class FbaPricingRepository { public int markInboundConfirmed(long tenantId, long inboundId) { return inbounds.update(null, new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) - .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT")) + .in(InboundPricingEntity::getProcessStatus, + List.of("FEE_CONFIRMING", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")) .eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 4162cc757..d89c86427 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -21,6 +21,7 @@ import org.springframework.util.StringUtils; import org.springframework.dao.DuplicateKeyException; import java.util.List; +import java.util.Set; /** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */ @Service @@ -73,8 +74,8 @@ public class SplitPreloadService { /** * A running PDA task may restore or add an unpicked allocation after the plan-level preload - * confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns - * the active PDA task lock and will synchronise the restored loading item immediately. + * confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED + * lifecycles may be reused while the restored loading item is synchronised atomically. */ @Transactional public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, @@ -96,7 +97,8 @@ public class SplitPreloadService { } if (plan != null) { if (!PreloadStatus.PENDING.equals(plan.getStatus()) - && !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { + && !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED) + .contains(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index 1b786f3de..05b512eca 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -190,6 +190,20 @@ class LoadingModelBridgeServiceTest { .hasMessageContaining("尚未全部完成装柜"); } + @Test + void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() { + when(repository.byPair(10, 20, 40)).thenReturn(null); + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now())); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(2); + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4)); + when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1); + + service.bindIncrementalAllocation(40L, 10L, 20L, 7L, "admin", "assign-confirming"); + + verify(repository).insert(any()); + verify(repository).confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin"); + } + private static Link link(String preloadStatus, int preloadVersion, String loadingStatus, int loadingVersion, int packages) { return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 16944ca9c..e64149613 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -26,6 +26,8 @@ class ContainerBookingMapperContractTest { .contains("fl_loading_plan") .contains("p.id AS plan_id") .contains("p.plan_no AS plan_no") + .contains("completed_loading_at") + .contains("p.confirmed_at") .contains("fl_container_exception_history") .contains("destination_clearance_document_status") .contains("delivery_order_document_status") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java new file mode 100644 index 000000000..1090a9a42 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContainerRepositoryLoadingTimeTest { + @Mock private ContainerBookingMapper mapper; + @InjectMocks private ContainerRepository repository; + + @Test + void projectsPlanConfirmationTimeWhenCompletedBookingHasNoSchedule() { + LocalDateTime completedAt = LocalDateTime.of(2026, 8, 27, 10, 15); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setPlanStatus("COMPLETED"); + booking.setCompletedLoadingAt(completedAt); + when(mapper.selectBooking(7L)).thenReturn(booking); + + ContainerBookingEntity result = repository.find(7L); + + assertThat(result.getLoadingAt()).isEqualTo(completedAt); + } + + @Test + void preservesExistingScheduleForUncompletedBooking() { + LocalDateTime plannedAt = LocalDateTime.of(2026, 8, 28, 9, 0); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setPlanStatus("LOADING"); + booking.setLoadingAt(plannedAt); + booking.setCompletedLoadingAt(LocalDateTime.of(2026, 8, 27, 10, 15)); + when(mapper.selectBooking(8L)).thenReturn(booking); + + ContainerBookingEntity result = repository.find(8L); + + assertThat(result.getLoadingAt()).isEqualTo(plannedAt); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 9e48a0b7d..48b879b7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -28,6 +28,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.Collection; import java.util.List; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -184,6 +187,20 @@ class FbaPricingRepositoryRuleListTest { .contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET"); } + @Test + void feeConfirmationAllowsPostLoadingInboundStatuses() { + String source; + try { + source = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"), + StandardCharsets.UTF_8); + } catch (java.io.IOException exception) { + throw new AssertionError(exception); + } + assertThat(source) + .contains("List.of(\"FEE_CONFIRMING\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")"); + } + private Collection queryValues(Long tenantId, String ruleCode) { when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 0cefc7b02..ac3e44ae8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -172,6 +172,33 @@ class FbaPricingServiceTest { verify(repository).markInboundConfirmed(TENANT, INBOUND); } + @Test + @DisplayName("装柜完成后的自动费用仍可确认") + void confirmsCalculatedFeeAfterLoadingCompleted() { + InboundPricingEntity inbound = inbound("10.000", "1.0000"); + inbound.setProcessStatus("LOADED"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity fee = savedFee("CALCULATED"); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded")) + .thenReturn(null); + doAnswer(invocation -> { + ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); + return null; + }).when(repository).insertIdempotency(any()); + when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(fee); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + FeeSnapshotResponse response = service.confirm( + TENANT, OPERATOR, INBOUND, "confirm-loaded"); + + assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); + verify(repository).markInboundConfirmed(TENANT, INBOUND); + } + @Test @DisplayName("审核未通过的测量快照禁止计费") void rejectsUnapprovedMeasurementSnapshot() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 41a4717ba..6734d229d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -380,6 +380,30 @@ class SmartLoadingServiceTest { anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void reusesConfirmingPreloadWhenAddingToRunningPlan() { + LoadingPlanEntity loading = plan("LOADING", 5); + LoadingPlanEntity after = plan("LOADING", 6); + when(repository.plan(1)).thenReturn(loading, after); + when(repository.booking(99)).thenReturn(booking()); + when(repository.touch(1, 5, 7, "u")).thenReturn(1); + when(repository.items(1)).thenReturn(List.of()); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "goods", 1, + BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null))); + + Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-confirming"), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); + verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, + 7L, "u", "assign-confirming"); + } + @Test void replaysBeforeVersionCheck() { AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 039693cf0..9d72c13a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -129,13 +129,14 @@ class SplitPreloadServiceTest { } @Test - void runningLoadingAllocationStillRejectsConfirmingPreload() { + void runningLoadingAllocationMayReuseConfirmingPreload() { when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMING"); when(repository.findPlan(10, 20)).thenReturn(plan); - assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) - .hasMessageContaining("已进入确认流程"); + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java index eafdadcc5..b38c2b733 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java @@ -41,7 +41,13 @@ public class NotificationRecipientResolver { if (user != null && "ACTIVE".equals(user.getStatus())) unique.put(item.getId(), new ResolvedRecipient( item.getTenantId(), item.getId(), item.getUserId(), user.getDisplayName(), item.getTenantId() == 0 ? "PLATFORM" : "MERCHANT")); }); - if (unique.isEmpty()) fail("NOTIFICATION_RECIPIENT_EMPTY"); + if (unique.isEmpty()) { + if (request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.TENANT_ROLE + || request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE) { + fail("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员"); + } + fail("未找到可接收消息的有效成员,请检查用户 ID 和目标企业"); + } return List.copyOf(unique.values()); } @@ -49,7 +55,7 @@ public class NotificationRecipientResolver { var role = roles.selectOne(new LambdaQueryWrapper() .eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId()) .eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1")); - if (role == null) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); + if (role == null) fail("角色编码不存在,请检查角色编码是否正确"); List ids = membershipRoles.selectList(new LambdaQueryWrapper() .eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId()) .eq(com.baiyue.iam.entity.IamMembershipRole::getRoleId,role.getId())).stream() diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index 629bfe02b..f1e524e2a 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -29,7 +29,7 @@ public class RbacRepository { public IamRole findRole(Long id, long tenantId, String appCode) { return roleMapper.selectOne(new LambdaQueryWrapper() - .eq(IamRole::getId, id).eq(IamRole::getTenantId, tenantId) + .eq(IamRole::getId, id).in(IamRole::getTenantId, List.of(0L, tenantId)) .eq(IamRole::getAppCode, appCode)); } @@ -80,7 +80,7 @@ public class RbacRepository { public List findRoles(long tenantId, String appCode) { return roleMapper.selectList(new LambdaQueryWrapper() - .eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode) + .in(IamRole::getTenantId, List.of(0L, tenantId)).eq(IamRole::getAppCode, appCode) .orderByAsc(IamRole::getRoleCode)); } @@ -109,7 +109,7 @@ public class RbacRepository { public Set findRolePermissionIds(long tenantId, Long roleId) { List rows = rolePermissionMapper.selectList( new LambdaQueryWrapper() - .eq(IamRolePermission::getTenantId, tenantId) + .in(IamRolePermission::getTenantId, List.of(0L, tenantId)) .eq(IamRolePermission::getRoleId, roleId) .orderByAsc(IamRolePermission::getPermissionId)); Set result = new LinkedHashSet<>(); diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index cc35971c5..2119e6fc6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -64,7 +64,8 @@ public class MemberService { List roleIds = repository.findRoleIds(tenantId, membership.getId()); List roleNames = roleIds.stream() .map(roleMapper::selectById) - .filter(role -> role != null && Long.valueOf(tenantId).equals(role.getTenantId())) + .filter(role -> role != null && "MERCHANT".equals(role.getAppCode()) + && (Long.valueOf(tenantId).equals(role.getTenantId()) || Long.valueOf(0L).equals(role.getTenantId()))) .map(IamRole::getRoleName) .toList(); return new PlatformCustomerMemberDtos.MemberResponse(membership.getId(), user.getId(), diff --git a/iam/src/main/resources/db/notification-center.sql b/iam/src/main/resources/db/notification-center.sql index 2f8e73959..bfd2f26ea 100644 --- a/iam/src/main/resources/db/notification-center.sql +++ b/iam/src/main/resources/db/notification-center.sql @@ -85,3 +85,10 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' WHERE p.tenant_id=0 AND p.permission_code LIKE 'platform:notification%' AND r.role_code='PLATFORM_ADMIN'; + +-- All active platform employee roles can receive and read their own inbox messages. +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) +FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' +WHERE p.tenant_id=0 + AND p.permission_code IN ('platform:notification:view','platform:notification:read','platform:notification:read-all'); diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index e184472d0..d92f62057 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -145,7 +145,7 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types. For a specified enterprise member, pass the user's ID and use USER; membership IDs are reserved for explicit membership-level calls.} roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java new file mode 100644 index 000000000..c388bbd20 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java @@ -0,0 +1,101 @@ +package com.baiyue.iam.notification; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest; +import com.baiyue.iam.notification.dto.NotificationDtos.RecipientType; +import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class NotificationRecipientResolverTest { + private final IamMembershipMapper memberships = mock(IamMembershipMapper.class); + private final IamUserMapper users = mock(IamUserMapper.class); + private final IamRoleMapper roles = mock(IamRoleMapper.class); + private final IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class); + private final NotificationRecipientResolver resolver = new NotificationRecipientResolver(); + + @BeforeEach + void setup() { + ReflectionTestUtils.setField(resolver, "memberships", memberships); + ReflectionTestUtils.setField(resolver, "users", users); + ReflectionTestUtils.setField(resolver, "roles", roles); + ReflectionTestUtils.setField(resolver, "membershipRoles", membershipRoles); + } + + @Test + void resolvesSpecifiedEnterpriseMemberByUserId() { + IamMembership membership = new IamMembership(); + membership.setId(301L); + membership.setTenantId(9L); + membership.setUserId(2092537408743079938L); + membership.setStatus("ACTIVE"); + IamUser user = new IamUser(); + user.setId(membership.getUserId()); + user.setDisplayName("测试成员"); + user.setStatus("ACTIVE"); + when(memberships.selectList(any())).thenReturn(List.of(membership)); + when(users.selectById(membership.getUserId())).thenReturn(user); + + var recipients = resolver.resolve(request(RecipientType.USER, List.of(membership.getUserId()))); + + assertThat(recipients).containsExactly(new NotificationRecipientResolver.ResolvedRecipient( + 9L, 301L, membership.getUserId(), "测试成员", "MERCHANT")); + } + + @Test + void explainsWhenNoActiveRecipientCanBeResolved() { + when(memberships.selectList(any())).thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(request(RecipientType.USER, List.of(2092537408743079938L)))) + .isInstanceOf(BizException.class) + .hasMessage("未找到可接收消息的有效成员,请检查用户 ID 和目标企业"); + } + + @Test + void explainsWhenRoleCodeDoesNotExist() { + when(roles.selectOne(any())).thenReturn(null); + + assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "MISSING_ROLE"))) + .isInstanceOf(BizException.class) + .hasMessage("角色编码不存在,请检查角色编码是否正确"); + } + + @Test + void explainsWhenRoleHasNoActiveMembers() { + IamRole role = new IamRole(); + role.setId(501L); + when(roles.selectOne(any())).thenReturn(role); + when(membershipRoles.selectList(any())).thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "CUSTOMER_REVIEWER"))) + .isInstanceOf(BizException.class) + .hasMessage("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员"); + } + + private CreateNotificationRequest request(RecipientType recipientType, List recipientIds) { + return new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", recipientType, recipientIds, + null, Map.of(), null, "notification-test-key", null, null); + } + + private CreateNotificationRequest roleRequest(RecipientType recipientType, String roleCode) { + return new CreateNotificationRequest(0L, "ORDER_STATUS_CHANGED", recipientType, null, + roleCode, Map.of(), null, "notification-role-test-key", null, null); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index b69d85274..6b12c273d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -71,7 +71,8 @@ class MemberServiceTest { when(tenantRepository.findTenant(100L)).thenReturn(tenant); when(repository.findByTenant(100L)).thenReturn(List.of(member)); IamRole ownerRole = new IamRole(); - ownerRole.setId(50L); ownerRole.setTenantId(100L); ownerRole.setRoleName("企业管理员"); + ownerRole.setId(50L); ownerRole.setTenantId(0L); ownerRole.setRoleName("企业管理员"); + ownerRole.setAppCode("MERCHANT"); when(roleMapper.selectById(50L)).thenReturn(ownerRole); when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(40L, "merchant_owner", "企业管理员", "ACTIVE"))); diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index 5968f9d0c..23734d6f5 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -71,7 +71,7 @@ public class MerchantFirstLegOrderController { @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody OrderRequests.SaveRequest request) { return ApiResponse.success(service.createDraft( - OrderScopeGuard.merchant(user), user.userId(), request, idempotencyKey)); + OrderScopeGuard.merchant(user), user.userId(), user.username(), request, idempotencyKey)); } @BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿") @@ -84,7 +84,7 @@ public class MerchantFirstLegOrderController { @RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody List items) { return ApiResponse.success(batchDraftService.create( - OrderScopeGuard.merchant(user), user.userId(), idempotencyKey, items)); + OrderScopeGuard.merchant(user), user.userId(), user.username(), idempotencyKey, items)); } @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿") diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index bb2594cfc..531e108c0 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -69,7 +69,7 @@ public class PlatformFirstLegOrderController { @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody OrderRequests.SaveRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey)); + return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), user.username(), request, idempotencyKey)); } @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料") diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java index 61b8e4bca..1ae6143a8 100644 --- a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java +++ b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java @@ -16,6 +16,7 @@ public record MerchantInboundOrderRow( String inboundCode, String channelName, String destinationType, + String pickupMethod, String customerName, String receivingCompanyName, String specificationModel, diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java index 29ac8acd3..717a3e1fa 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java @@ -20,6 +20,12 @@ public class MerchantBatchDraftItemService { @Transactional(propagation = Propagation.REQUIRES_NEW) public OrderEntity create(long tenantId, long userId, OrderRequests.SaveRequest draft, String idempotencyKey) { + return create(tenantId, userId, null, draft, idempotencyKey); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public OrderEntity create(long tenantId, long userId, String username, + OrderRequests.SaveRequest draft, String idempotencyKey) { var violations = validator.validate(draft); if (!violations.isEmpty()) { String message = violations.stream() @@ -29,7 +35,7 @@ public class MerchantBatchDraftItemService { .orElse("草稿参数不合法"); throw new BizException(ResultCode.VALIDATION_FAILED, message); } - Long id = orders.createDraft(tenantId, userId, draft, idempotencyKey); + Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey); return repository.findOwned(tenantId, id); } } diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java index dfa6bbd1a..1614bd39f 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java @@ -22,6 +22,11 @@ public class MerchantBatchDraftService { public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey, List items) { + return create(tenantId, userId, null, batchKey, items); + } + + public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String username, + String batchKey, List items) { if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key"); if (batchKey.trim().length() > 128) throw new BizException("Idempotency-Key 长度不能超过128"); if (items == null || items.isEmpty()) throw new BizException("批量草稿不能为空"); @@ -44,7 +49,7 @@ public class MerchantBatchDraftService { continue; } try { - OrderEntity order = itemService.create(tenantId, userId, item.draft(), derivedKey(batchKey, rowId)); + OrderEntity order = itemService.create(tenantId, userId, username, item.draft(), derivedKey(batchKey, rowId)); results.add(MerchantBatchDraftDtos.Result.success(index, rowId, order.getId(), order.getOrderNo(), order.getInboundCode())); } catch (Exception ex) { String code = ex instanceof BizException be ? be.getResultCode().code() : "S0500"; diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java index 726442cc9..6772a5ca1 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java @@ -128,7 +128,7 @@ public class MerchantInboundOrderListService { inbound == null ? null : inbound.inboundId(), inbound == null || inbound.inboundCode() == null || inbound.inboundCode().isBlank() ? order.getInboundCode() : inbound.inboundCode(), order.getChannelName(), - order.getDestinationType(), order.getCustomerName(), + order.getDestinationType(), order.getPickupMethod(), order.getCustomerName(), consignee == null ? null : consignee.getCompanyName(), joined(cargoes, OrderCargoEntity::getModel), order.getStatus(), totalQuantity, grossWeight, netWeight, volume, commonUnitPrice(cargoes), declaredAmount, joined(cargoes, OrderCargoEntity::getCurrency), diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 9f2f9f841..7687b485f 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -59,17 +59,25 @@ public class OrderApplicationService { /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) { + return createDraft(tenantId, userId, null, request, idempotencyKey); + } + + /** 创建草稿时同时保存创建操作人的名称快照,供平台订单详情展示。 */ + @Transactional + public Long createDraft(long tenantId, long userId, String username, + OrderRequests.SaveRequest request, String idempotencyKey) { IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "CREATE_DRAFT", 0L, idempotencyKey, requestHash("CREATE_DRAFT", request)); if (idempotency.replayed()) { return idempotency.record().getResultOrderId(); } - Long orderId = createDraftInternal(tenantId, userId, request); + Long orderId = createDraftInternal(tenantId, userId, username, request); completeIdempotency(idempotency, orderId); return orderId; } - private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { + private Long createDraftInternal(long tenantId, long userId, String username, + OrderRequests.SaveRequest request) { productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), @@ -86,7 +94,7 @@ public class OrderApplicationService { throw duplicateInboundCode(order, duplicate); } saveChildren(tenantId, userId, order.getId(), request); - record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); + record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, username); return order.getId(); } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index ffc055646..b74fc11cd 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -79,8 +79,18 @@ public class UnifiedOrderQueryService { FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED ? null : firstlegDetail(tenantId, orderId); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); + String creatorName = orderDetail.statusHistory().stream() + .filter(item -> "CREATE".equals(item.getAction())) + .map(item -> item.getOperatorName()) + .filter(StringUtils::hasText) + .findFirst() + .orElseGet(() -> orderDetail.statusHistory().stream() + .map(item -> item.getOperatorName()) + .filter(StringUtils::hasText) + .findFirst() + .orElse(null)); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote, - childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes())); + childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), @@ -174,6 +184,12 @@ public class UnifiedOrderQueryService { private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote, Map childCounts, CargoSummary cargo) { + return row(order, summary, latestInternalNote, childCounts, cargo, null); + } + + private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, + String latestInternalNote, Map childCounts, + CargoSummary cargo, String creatorName) { if (summary == null) { String mainStatus = orderMainStatus(order.getStatus()); return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, @@ -187,7 +203,7 @@ public class UnifiedOrderQueryService { null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(), - string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), + StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), null, null, null, null, null, order.getCustomerRemark(), latestInternalNote, order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt()); @@ -206,7 +222,7 @@ public class UnifiedOrderQueryService { inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), - inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), + StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote, order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt()); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 114b1e06e..1d9340a63 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -748,6 +748,7 @@ components: inboundId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} inboundCode: {type: string} channelName: {type: string} + pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true} destinationWarehouseName: {type: string} status: {type: string} packageCount: {type: integer} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java index 5a6c9b4d5..787ad5f6e 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java @@ -41,6 +41,7 @@ class MerchantInboundOrderListServiceTest { order.setStatus(OrderStatus.APPROVED); order.setChannelName("美森"); order.setDestinationType("FBA"); + order.setPickupMethod("SELF_TRUCK"); order.setDestinationWarehouseName("ONT8"); order.setCustomerName("测试客户"); order.setShipmentId("FBA-SHIPMENT-77"); @@ -82,6 +83,7 @@ class MerchantInboundOrderListServiceTest { assertThat(row.receivingCompanyName()).isEqualTo("收货公司"); assertThat(row.operatorName()).isEqualTo("操作员"); assertThat(row.destinationWarehouseName()).isEqualTo("ONT8"); + assertThat(row.pickupMethod()).isEqualTo("SELF_TRUCK"); assertThat(row.expressCompany()).isEqualTo("UPS"); assertThat(row.skuNos()).isEqualTo("SKU-A、SKU-B"); assertThat(row.shipmentId()).isEqualTo("FBA-SHIPMENT-77"); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 7f371db00..e151bbdf9 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; +import com.baiyue.order.entity.OrderStatusHistoryEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; import jakarta.annotation.Resource; @@ -247,6 +248,24 @@ class UnifiedOrderQueryServiceTest { verify(firstleg, never()).detail(any(), any()); } + @Test + void detailUsesCreateHistoryOperatorNameInsteadOfNumericCreatorId() { + OrderEntity order = order(OrderStatus.SUBMITTED); + OrderStatusHistoryEntity created = new OrderStatusHistoryEntity(); + created.setAction("CREATE"); + created.setOperatorId(2L); + created.setOperatorName("platform_admin"); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of(created)); + + var result = service.detail(10001L, 77L, true); + + assertThat(result.summary().createdBy()).isEqualTo("platform_admin"); + } + @Test void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { UnifiedOrderQuery query = query("INBOUNDED"); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java index 365c65d93..d83cbca1f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java @@ -26,11 +26,9 @@ public class PlatformCustomerOverseasStorageFeeController { throw new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户ID与路径不一致"); } - if (request.tiers() == null || request.tiers().size() != 9 - || request.tiers().stream().filter(t -> "STORAGE".equals(t.feeType())).count() != 5 - || request.tiers().stream().filter(t -> "RETURN_STORAGE".equals(t.feeType())).count() != 4) { + if (request.tiers() == null || request.tiers().isEmpty()) { throw new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户费用必须包含仓租5档和退货仓储费4档"); + com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户费用至少需要包含1个档位"); } OverseasStorageFeeDtos.View view = service.detailByTenant(tenantId); service.update(view.id(), request); return ApiResponse.success(null); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java index 729ade0a6..67150d0a9 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -127,6 +128,31 @@ class OverseasStorageFeeServiceTest { verify(repository, never()).updateRule(any()); } + @Test + void replacesExistingTiersWhenAddingOrRemovingAGroupTier() { + OverseasStorageFeeRuleEntity rule = rule(); + when(repository.find(10L)).thenReturn(rule); + List tiers = List.of( + typedTier("STORAGE", "CBM", 1, 30), + typedTier("STORAGE", "CBM", 31, null), + typedTier("RETURN_STORAGE", "PALLET", 1, 60), + typedTier("RETURN_STORAGE", "PALLET", 61, null)); + OverseasStorageFeeDtos.UpdateRequest request = new OverseasStorageFeeDtos.UpdateRequest( + 10001L, "绀轰緥瀹㈡埛", "USD", tiers, 1); + when(repository.updateRule(any())).thenReturn(1); + + service.update(10L, request); + + var calls = inOrder(repository); + calls.verify(repository).updateRule(rule); + calls.verify(repository).deleteTiers(10L); + calls.verify(repository).insertTiers(org.mockito.ArgumentMatchers.argThat(saved -> + saved.size() == 4 + && saved.stream().filter(tier -> "STORAGE".equals(tier.getFeeType())).count() == 2 + && saved.stream().filter(tier -> "RETURN_STORAGE".equals(tier.getFeeType())).count() == 2 + && saved.stream().anyMatch(tier -> tier.getStartDay() == 31 && tier.getEndDay() == null))); + } + @Test void initializesNineDefaultTiersIdempotently() { doAnswer(invocation -> { OverseasStorageFeeRuleEntity rule = invocation.getArgument(0); rule.setId(77L); return null; })