fix(order): align FBA submit and fee confirmation

This commit is contained in:
2026-09-14 17:19:06 +08:00
parent e0af45db02
commit 8a1e587ee4
13 changed files with 103 additions and 12 deletions
@@ -7,6 +7,7 @@ public record UnifiedInboundSummary(
InboundOrderSummary inbound,
String mainStatus,
String nextAction,
Boolean customerFeeConfirmationRequired,
PreloadSummary preload,
LoadingSummary loading,
DeliverySummary delivery
@@ -22,7 +22,9 @@ import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** FBA 计费仓储;平台基础资料列表可跨企业读取,其他业务操作强制带租户条件。 */
@Repository
@@ -250,6 +252,24 @@ public class FbaPricingRepository {
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
}
public Map<Long, Boolean> customerFeeConfirmationRequiredMap(
Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound == null || tenantByInbound.isEmpty()) return Map.of();
var query = new LambdaQueryWrapper<InboundFeeEntity>()
.in(InboundFeeEntity::getInboundId, tenantByInbound.keySet());
if (tenantId != null) query.eq(InboundFeeEntity::getTenantId, tenantId);
query.orderByDesc(InboundFeeEntity::getFeeVersion)
.orderByDesc(InboundFeeEntity::getId);
Map<Long, Boolean> result = new LinkedHashMap<>();
for (InboundFeeEntity fee : fees.selectList(query)) {
Long expectedTenantId = tenantByInbound.get(fee.getInboundId());
if (expectedTenantId == null || !expectedTenantId.equals(fee.getTenantId())) continue;
result.putIfAbsent(fee.getInboundId(),
"SENT".equals(fee.getFeeStatus()) && fee.getCustomerConfirmedAt() == null);
}
return result;
}
public InboundFeeEntity findLatestFeeForUpdate(long tenantId, long inboundId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.dto.CurrentContainerTransport;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
import com.fasterxml.jackson.databind.JsonNode;
@@ -32,6 +33,7 @@ public class UnifiedInboundQueryService {
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@Resource private FbaPricingRepository pricing;
@Resource private UnifiedInboundReadRepository operations;
@Resource private ObjectMapper json;
@@ -79,8 +81,10 @@ public class UnifiedInboundQueryService {
}
CurrentContainerTransport transport = includePhaseThree
? operations.findTransport(tenantId, inbound.getId()) : null;
boolean customerFeeConfirmationRequired = pricing.customerFeeConfirmationRequiredMap(
tenantId, Map.of(inbound.getId(), inbound.getTenantId())).getOrDefault(inbound.getId(), false);
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
transport, null, null), timeline);
transport, null, null, customerFeeConfirmationRequired), timeline);
}
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
@@ -99,10 +103,12 @@ public class UnifiedInboundQueryService {
records.forEach(inbound -> applyConfirmedPalletCount(inbound, confirmedPalletCounts));
var locationCodes = inbounds.locationCodesMap(null, owners);
var pickups = inbounds.pickupMap(null, owners);
var customerFeeConfirmations = pricing.customerFeeConfirmationRequiredMap(null, owners);
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(),
transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList();
transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()),
customerFeeConfirmations.getOrDefault(inbound.getId(), false))).toList();
}
private static void applyConfirmedPalletCount(InboundOrderEntity inbound,
@@ -118,7 +124,8 @@ public class UnifiedInboundQueryService {
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<CabinetLoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
CurrentContainerTransport transport, String locationCodes,
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup,
boolean customerFeeConfirmationRequired) {
String mainStatus = mainStatus(inbound.getProcessStatus());
List<CabinetLoadingEvidenceEntity> activeEvidence = evidence.stream()
.filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList();
@@ -143,6 +150,7 @@ public class UnifiedInboundQueryService {
locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes);
return new UnifiedInboundSummary(inboundSummary, mainStatus,
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
customerFeeConfirmationRequired,
preloadSummary, loadingSummary, deliverySummary);
}
@@ -1573,6 +1573,7 @@ components:
inbound: {$ref: '#/components/schemas/UnifiedInboundOrderSummary'}
mainStatus: {$ref: '#/components/schemas/UnifiedMainStatus'}
nextAction: {type: string, nullable: true}
customerFeeConfirmationRequired: {type: boolean, description: 最新费用已发送给客户且尚未由客户确认}
preload: {$ref: '#/components/schemas/UnifiedPreloadSummary'}
loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'}
delivery: {$ref: '#/components/schemas/UnifiedDeliverySummary'}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
@@ -30,6 +31,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -58,6 +60,8 @@ class FbaPricingRepositoryRuleListTest {
CarrierFbaRateEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"),
InboundPricingEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"),
InboundFeeEntity.class);
}
@Test
@@ -204,6 +208,29 @@ class FbaPricingRepositoryRuleListTest {
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
}
@Test
void customerFeeConfirmationIsRequiredOnlyForLatestUnconfirmedSentFee() {
InboundFeeEntity waiting = new InboundFeeEntity();
waiting.setId(12L);
waiting.setTenantId(10001L);
waiting.setInboundId(88L);
waiting.setFeeVersion(2);
waiting.setFeeStatus("SENT");
InboundFeeEntity confirmed = new InboundFeeEntity();
confirmed.setId(22L);
confirmed.setTenantId(10001L);
confirmed.setInboundId(99L);
confirmed.setFeeVersion(1);
confirmed.setFeeStatus("SENT");
confirmed.setCustomerConfirmedAt(java.time.LocalDateTime.now());
when(fees.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(waiting, confirmed));
assertThat(repository.customerFeeConfirmationRequiredMap(
10001L, Map.of(88L, 10001L, 99L, 10001L)))
.containsEntry(88L, true)
.containsEntry(99L, false);
}
@Test
void dropshipInitialPricingPreservesWaitingForPalletization() {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
import org.junit.jupiter.api.Test;
@@ -91,9 +92,11 @@ class UnifiedInboundQueryServiceTest {
void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) {
InboundOrderRepository inbounds = mock(InboundOrderRepository.class);
UnifiedInboundReadRepository operations = mock(UnifiedInboundReadRepository.class);
FbaPricingRepository pricing = mock(FbaPricingRepository.class);
UnifiedInboundQueryService service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "inbounds", inbounds);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "pricing", pricing);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(88L);
@@ -103,10 +106,13 @@ class UnifiedInboundQueryServiceTest {
InboundOrderDetail detail = new InboundOrderDetail(null, null, null, null, null, List.of());
when(inbounds.findByOrderId(10001L, 99L)).thenReturn(inbound);
when(inbounds.detail(10001L, inbound)).thenReturn(detail);
when(pricing.customerFeeConfirmationRequiredMap(10001L, Map.of(88L, 10001L)))
.thenReturn(Map.of(88L, true));
UnifiedInboundDetail result = service.detail(10001L, 99L);
assertThat(result).isNotNull();
assertThat(result.summary().customerFeeConfirmationRequired()).isTrue();
verifyNoInteractions(operations);
}
@@ -38,7 +38,7 @@ public interface FirstlegUnifiedOrderClient {
record BatchRequest(Long tenantId, List<Long> orderIds) {}
record DetailRequest(long tenantId, long orderId) {}
record Summary(Inbound inbound, String mainStatus, String nextAction,
record Summary(Inbound inbound, String mainStatus, String nextAction, Boolean customerFeeConfirmationRequired,
Preload preload, Loading loading, Delivery delivery) {}
record Detail(Summary summary, List<Timeline> timeline) {}
record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
@@ -13,6 +13,7 @@ public final class UnifiedOrderResponse {
public record Row(
Long orderId, Integer orderVersion, Long tenantId, String orderNo, String mainStatus, String nextAction,
Boolean customerFeeConfirmationRequired,
OrderRelationSummary relation,
OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelCode, String channelName, Boolean channelChanged,
@@ -348,11 +348,11 @@ public class OrderApplicationService {
"FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : cargoes) {
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())
if (!StringUtils.hasText(cargo.getSkuNo())
|| !StringUtils.hasText(cargo.getShipmentId())
|| !StringUtils.hasText(cargo.getReferenceId())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
"FBA cargo requires SKU NO., Shipment ID and Reference ID.");
}
}
}
@@ -387,10 +387,10 @@ public class OrderApplicationService {
"FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
if (!StringUtils.hasText(cargo.skuNo())
|| !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
"FBA cargo requires SKU NO., Shipment ID and Reference ID.");
}
}
}
@@ -316,6 +316,7 @@ public class UnifiedOrderQueryService {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getVersion(), order.getTenantId(), order.getOrderNo(), mainStatus,
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
false,
relation(order, childCounts),
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
@@ -337,7 +338,7 @@ public class UnifiedOrderQueryService {
String mainStatus = pendingReview ? "PENDING_REVIEW" : summary.mainStatus();
String nextAction = pendingReview ? "APPROVE_ORDER" : summary.nextAction();
return new UnifiedOrderResponse.Row(order.getId(), order.getVersion(), order.getTenantId(), order.getOrderNo(),
mainStatus, nextAction, relation(order, childCounts), order.getStatus(),
mainStatus, nextAction, Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
@@ -366,7 +367,7 @@ public class UnifiedOrderQueryService {
UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
summary.mainStatus(), summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts),
parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
@@ -893,6 +893,7 @@ components:
orderNo: {type: string}
mainStatus: {$ref: '#/components/schemas/UnifiedMainStatus'}
nextAction: {type: string, nullable: true}
customerFeeConfirmationRequired: {type: boolean, description: Firstleg 已向客户发送最新费用且客户尚未确认时为 true}
relation: {$ref: '#/components/schemas/UnifiedOrderRelation'}
internalOrderStatus: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED], nullable: true}
customerId: {type: integer, format: int64, nullable: true}
@@ -1289,7 +1290,7 @@ components:
dangerousGoods: {type: boolean}
woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
原型货物明细字段。FBA 货物的 skuNo、shipmentId、referenceId 由服务端按每条货物校验,upcNo 选填;
productImageFileId 必须属于当前登录客户。一件代发中,相同 packingGroupNo 的多条 SKU 行属于同一批物理箱,物理箱数只计算一次。
FirstLegOrderSummary:
type: object
@@ -306,6 +306,30 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order);
}
@Test
void submittingFbaDraftKeepsUpcOptional() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "FBA");
order.setAmazonWarehouseCode("GEU2");
order.setShipmentId("FBA19PS4C6SP");
order.setReferenceId("2F6MA5QD");
OrderCargoEntity cargo = completeCargo();
cargo.setSkuNo("Shoes");
cargo.setShipmentId("FBA19PS4C6SP");
cargo.setReferenceId("2F6MA5QD");
cargo.setUpcNo(null);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
when(repository.saveOrder(20002L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).saveOrder(20002L, order);
}
@Test
void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() {
OrderRepository repository = mock(OrderRepository.class);
@@ -683,6 +683,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(result.summary().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L);
assertThat(result.summary().customerFeeConfirmationRequired()).isTrue();
assertThat(result.summary().holdFlag()).isTrue();
assertThat(result.summary().currentHoldReason()).isEqualTo("等待客户补充资料");
ArgumentCaptor<FirstlegUnifiedOrderClient.DetailRequest> captor =
@@ -934,7 +935,7 @@ class UnifiedOrderQueryServiceTest {
when(inbound.chargeableWeightKg()).thenReturn(new BigDecimal("125.500"));
when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, true,
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);
}