Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -99,7 +99,12 @@ public class FinanceQueryService {
|
||||
}
|
||||
|
||||
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
||||
return feePool(customerId, direction, null);
|
||||
}
|
||||
|
||||
public List<FeePoolItem> 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<FeePoolItem> result = new ArrayList<>();
|
||||
@@ -107,6 +112,14 @@ public class FinanceQueryService {
|
||||
List<Object> 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");
|
||||
|
||||
@@ -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<String> sourceDirections = new ArrayList<>();
|
||||
private int costQueryCount;
|
||||
private String sourceSql;
|
||||
private List<Object> sourceArgs = List.of();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> List<T> query(String sql, RowMapper<T> 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<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
@Update("UPDATE fl_preload_plan SET status='CONFIRMED',confirmed_by=#{operatorId},"
|
||||
+ "confirmed_by_name=#{operatorName},confirmed_at=NOW(),updated_by=#{operatorId},"
|
||||
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{preloadPlanId} "
|
||||
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status='PENDING' "
|
||||
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status IN ('PENDING','CONFIRMING') "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int confirmIncrementalPreload(@Param("preloadPlanId") long preloadPlanId,
|
||||
@Param("tenantId") long tenantId,
|
||||
|
||||
+4
-2
@@ -125,7 +125,9 @@ public class LoadingModelBridgeService {
|
||||
}
|
||||
var preload = repository.preload(tenantId, inboundId);
|
||||
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) {
|
||||
boolean running = "LOADING".equals(plan.status());
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())
|
||||
&& !(running && "CONFIRMING".equals(preload.status()))) {
|
||||
throw conflict("新增入库单预装状态不允许进入当前柜次");
|
||||
}
|
||||
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
|
||||
@@ -145,7 +147,7 @@ public class LoadingModelBridgeService {
|
||||
// A running PDA task has already passed plan-level confirmation. Adding an inbound is the
|
||||
// explicit Admin approval for that new allocation, so its fresh PENDING preload joins the
|
||||
// same confirmed plan atomically instead of leaving an unreachable pending aggregate.
|
||||
if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status())
|
||||
if (running && Set.of("PENDING", "CONFIRMING").contains(preload.status())
|
||||
&& repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId,
|
||||
preload.version(), operatorId, operatorName) != 1) {
|
||||
throw conflict("新增入库单预装确认并发冲突,请刷新后重试");
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("""
|
||||
<script>
|
||||
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<ContainerBookingEntit
|
||||
|
||||
@Select("""
|
||||
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,
|
||||
|
||||
@@ -13,9 +13,12 @@ import org.springframework.stereotype.Repository;
|
||||
public class ContainerRepository {
|
||||
@Resource private ContainerBookingMapper mapper;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
|
||||
Page<ContainerBookingEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ public class FbaPricingRepository {
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
@@ -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",
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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<Object> queryValues(Long tenantId, String ruleCode) {
|
||||
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+4
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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<com.baiyue.iam.entity.IamRole>()
|
||||
.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<Long> ids = membershipRoles.selectList(new LambdaQueryWrapper<com.baiyue.iam.entity.IamMembershipRole>()
|
||||
.eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId())
|
||||
.eq(com.baiyue.iam.entity.IamMembershipRole::getRoleId,role.getId())).stream()
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RbacRepository {
|
||||
|
||||
public IamRole findRole(Long id, long tenantId, String appCode) {
|
||||
return roleMapper.selectOne(new LambdaQueryWrapper<IamRole>()
|
||||
.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<IamRole> findRoles(long tenantId, String appCode) {
|
||||
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
|
||||
.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<Long> findRolePermissionIds(long tenantId, Long roleId) {
|
||||
List<IamRolePermission> rows = rolePermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<IamRolePermission>()
|
||||
.eq(IamRolePermission::getTenantId, tenantId)
|
||||
.in(IamRolePermission::getTenantId, List.of(0L, tenantId))
|
||||
.eq(IamRolePermission::getRoleId, roleId)
|
||||
.orderByAsc(IamRolePermission::getPermissionId));
|
||||
Set<Long> result = new LinkedHashSet<>();
|
||||
|
||||
@@ -64,7 +64,8 @@ public class MemberService {
|
||||
List<Long> roleIds = repository.findRoleIds(tenantId, membership.getId());
|
||||
List<String> 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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
+2
-2
@@ -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<MerchantBatchDraftDtos.Item> 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 / 修改草稿")
|
||||
|
||||
+1
-1
@@ -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 = "平台编辑订单资料")
|
||||
|
||||
@@ -16,6 +16,7 @@ public record MerchantInboundOrderRow(
|
||||
String inboundCode,
|
||||
String channelName,
|
||||
String destinationType,
|
||||
String pickupMethod,
|
||||
String customerName,
|
||||
String receivingCompanyName,
|
||||
String specificationModel,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ public class MerchantBatchDraftService {
|
||||
|
||||
public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey,
|
||||
List<MerchantBatchDraftDtos.Item> items) {
|
||||
return create(tenantId, userId, null, batchKey, items);
|
||||
}
|
||||
|
||||
public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String username,
|
||||
String batchKey, List<MerchantBatchDraftDtos.Item> 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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UnifiedOrderResponse.TimelineEvent> 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<Long, Integer> childCounts,
|
||||
CargoSummary cargo) {
|
||||
return row(order, summary, latestInternalNote, childCounts, cargo, null);
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary,
|
||||
String latestInternalNote, Map<Long, Integer> 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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
+2
-4
@@ -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);
|
||||
|
||||
+26
@@ -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<OverseasStorageFeeDtos.TierRequest> 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; })
|
||||
|
||||
Reference in New Issue
Block a user