Compare commits
3
Commits
920a7e083d
...
766e199c9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766e199c9b | ||
|
|
5368da03d8 | ||
|
|
a563f63857 |
+5
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
|
||||
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
|
||||
}
|
||||
|
||||
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
|
||||
public List<String> destinations() {
|
||||
return scope().destinations().stream().distinct().sorted().toList();
|
||||
}
|
||||
|
||||
private record Scope(List<String> origins, List<String> destinations) { }
|
||||
private Scope scope() {
|
||||
final List<AppointmentWarehouseClient.Warehouse> catalog;
|
||||
|
||||
+8
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
|
||||
return ApiResponse.success(new Resolved(source,result.get(0)));
|
||||
}
|
||||
|
||||
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
|
||||
@GetMapping("/cargo/destinations")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.destinations());
|
||||
}
|
||||
|
||||
@GetMapping("/cargo")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -6,6 +6,26 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/appointment-planning/cargo/destinations:
|
||||
get:
|
||||
operationId: listAppointmentCargoDestinations
|
||||
summary: 读取预约货物查询的目的FBA仓选项
|
||||
description: >-
|
||||
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
与货物分页共用权威启用美国FBA目录,返回去重、按仓码排序的完整选项,不受货物当前分页或关键词限制。
|
||||
不增加仓库主档读取权限;目录失败返回503,不当作空选项。
|
||||
responses:
|
||||
'200':
|
||||
description: 业务响应;data为目的FBA仓码数组
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data: {type: array, items: {type: string}, example: [ABE4, ONT8]}
|
||||
'403': {description: 非平台或缺少任一读取权限}
|
||||
'503': {description: 权威目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
|
||||
get:
|
||||
operationId: resolveOrderAppointmentSource
|
||||
|
||||
+15
@@ -8,6 +8,21 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentCargoServiceTest {
|
||||
/** 选项取完整权威美国FBA目录,不依赖当前页货物或新增仓库主档权限。 */
|
||||
@Test void destinationOptionsUseTheSameAmericanFbaScopeWithoutQueryingCargoRows() {
|
||||
var mapper = mock(AppointmentCargoMapper.class);
|
||||
var warehouses = mock(AppointmentWarehouseClient.class);
|
||||
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
|
||||
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(2L,"ONT8","ONT8","FBA","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(3L,"ABE4","ABE4","FBA","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"),
|
||||
new AppointmentWarehouseClient.Warehouse(5L,"ONT8","ONT8","FBA","US"))));
|
||||
assertEquals(List.of("ABE4","ONT8"),new AppointmentCargoService(mapper,warehouses,"token").destinations());
|
||||
verifyNoInteractions(mapper);
|
||||
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
|
||||
assertEquals(503,assertThrows(ResponseStatusException.class,()->new AppointmentCargoService(mapper,warehouses,"token").destinations()).getStatusCode().value());
|
||||
}
|
||||
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
|
||||
var mapper = mock(AppointmentCargoMapper.class);
|
||||
var warehouses = mock(AppointmentWarehouseClient.class);
|
||||
|
||||
+14
@@ -17,6 +17,20 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentPlanningAuthorizationTest {
|
||||
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
|
||||
/** 目的仓选项不要求仓库管理权限,但保留原两项读取权限和平台身份边界。 */
|
||||
@Test void destinationOptionsRequireTheExistingCargoReadPermissionsAndPlatformIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentPlanningController.class);
|
||||
var service=context.getBean(AppointmentCargoService.class);
|
||||
login(LIST);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
|
||||
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
|
||||
login(LIST,CARGO);
|
||||
assertThrows(ResponseStatusException.class,()->controller.destinations(user(1L,"CLIENT")));
|
||||
verifyNoInteractions(service);
|
||||
when(service.destinations()).thenReturn(List.of("ABE4","ONT8"));
|
||||
assertEquals(List.of("ABE4","ONT8"),controller.destinations(user(0L,"PLATFORM")).data());
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentPlanningController.class);
|
||||
|
||||
@@ -81,7 +81,9 @@ public final class OrderRequests {
|
||||
@Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填")
|
||||
@Min(1) Long senderAddressId,
|
||||
@Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填")
|
||||
@Min(1) Long privateDestinationAddressId
|
||||
@Min(1) Long privateDestinationAddressId,
|
||||
@Schema(description = "Optional customer courier tracking number for SELF_EXPRESS / 快递自提选填快递单号;其他取货方式不保存", maxLength = 128)
|
||||
@Size(max = 128) String expressTrackingNo
|
||||
) {
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
String destinationCountryCode, Long destinationWarehouseId,
|
||||
@@ -98,7 +100,7 @@ public final class OrderRequests {
|
||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null);
|
||||
shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null, null);
|
||||
}
|
||||
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
@@ -116,7 +118,7 @@ public final class OrderRequests {
|
||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId, inboundCode, null, null, null, null);
|
||||
shipmentId, referenceId, inboundCode, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
@@ -133,7 +135,7 @@ public final class OrderRequests {
|
||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId, null, null, null, null, null);
|
||||
shipmentId, referenceId, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
@@ -150,7 +152,7 @@ public final class OrderRequests {
|
||||
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId, null, null, null, null, null);
|
||||
shipmentId, referenceId, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
|
||||
|
||||
@@ -642,6 +642,7 @@ public class OrderApplicationService {
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
// 保存客户下单资料;快递单号仅属于快递自提,切换方式时清空避免误带。
|
||||
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
|
||||
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses,
|
||||
boolean allowLegacyInboundCode) {
|
||||
@@ -662,6 +663,10 @@ public class OrderApplicationService {
|
||||
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
|
||||
order.setCustomerRemark(request.customerRemark());
|
||||
order.setPickupMethod(request.pickupMethod());
|
||||
if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED) {
|
||||
order.setExpressTrackingNo("SELF_EXPRESS".equals(request.pickupMethod())
|
||||
? normalizeRemark(request.expressTrackingNo()) : null);
|
||||
}
|
||||
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
|
||||
order.setReceivingWarehouseId(request.receivingWarehouseId());
|
||||
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
|
||||
|
||||
@@ -162,7 +162,7 @@ public class PlatformLegacyOrderImportService {
|
||||
"买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND",
|
||||
channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED",
|
||||
"LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType,
|
||||
"ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null);
|
||||
"ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null, null);
|
||||
}
|
||||
|
||||
private Customer fixedCustomer() {
|
||||
|
||||
@@ -1451,6 +1451,7 @@ components:
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
|
||||
privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址}
|
||||
expressTrackingNo: {type: string, nullable: true, maxLength: 128, description: 快递自提(SELF_EXPRESS)时选填;留空清空单号,其他取货方式不保存。客户仅能在下单或编辑草稿时填写}
|
||||
customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true}
|
||||
cargoes:
|
||||
type: array
|
||||
|
||||
@@ -52,6 +52,24 @@ class MerchantOrderCommandBindingTest {
|
||||
}).build();
|
||||
}
|
||||
|
||||
/** 客户创建入口绑定选填单号,并拒绝超过持久化长度的输入。 */
|
||||
@Test
|
||||
void courierTrackingIsOptionalAndLengthBoundedOnCreate() throws Exception {
|
||||
String createPath = "/api/merchant/v1/orders/first-leg";
|
||||
for (String tracking : new String[] {null, "", "SF123456789"}) {
|
||||
String body = tracking == null ? DRAFT : DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + tracking + "\",\"customerName\"");
|
||||
mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
|
||||
verify(service).createDraft(eq(11L), eq(7L), eq("operator"),
|
||||
argThat(request -> java.util.Objects.equals(tracking, request.expressTrackingNo())), eq("courier-create"));
|
||||
}
|
||||
clearInvocations(service);
|
||||
String invalid = DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + "A".repeat(129) + "\",\"customerName\"");
|
||||
mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(invalid))
|
||||
.andExpect(status().isBadRequest());
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
|
||||
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
|
||||
|
||||
@@ -111,6 +111,58 @@ class OrderApplicationServiceStateTest {
|
||||
&& order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType())));
|
||||
}
|
||||
|
||||
/** 快递草稿支持填写或留空单号,非快递方式不能携带单号。 */
|
||||
@Test
|
||||
void courierDraftPersistsOptionalTrackingNumber() {
|
||||
for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) {
|
||||
for (String tracking : List.of(" SF123456789 ", "", " ")) {
|
||||
var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS"));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking);
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete");
|
||||
var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class);
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
when(masterData.validate(10001L, draft)).thenReturn(snapshots());
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(10001L, 2L, draft, "courier-draft");
|
||||
|
||||
String expected = "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank()
|
||||
? tracking.trim() : null;
|
||||
verify(repository).insertOrder(argThat(saved -> java.util.Objects.equals(expected, saved.getExpressTrackingNo())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改草稿允许更换、清空单号,切换交货方式后不保留旧单号。 */
|
||||
@Test
|
||||
void editingCourierDraftReplacesOrClearsTrackingNumber() {
|
||||
for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) {
|
||||
for (String tracking : List.of(" SF-NEW ", "")) {
|
||||
var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS"));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking);
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete");
|
||||
var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class);
|
||||
var order = persistedOrder(OrderStatus.DRAFT);
|
||||
order.setExpressTrackingNo("SF-OLD");
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
when(repository.saveOrder(10001L, order)).thenReturn(1);
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
when(masterData.validate(10001L, draft)).thenReturn(snapshots());
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData).updateDraft(10001L, 2L, 1L,
|
||||
new OrderRequests.DraftUpdateRequest(0, draft), "courier-edit");
|
||||
|
||||
assertThat(order.getExpressTrackingNo()).isEqualTo(
|
||||
"SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() ? tracking.trim() : null);
|
||||
verify(repository).saveOrder(10001L, order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingDraftPreservesProductImageDimensionsAndRemark() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
Reference in New Issue
Block a user