Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766e199c9b | ||
|
|
5368da03d8 | ||
|
|
920a7e083d | ||
|
|
a563f63857 |
+5
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
|
|||||||
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
|
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 record Scope(List<String> origins, List<String> destinations) { }
|
||||||
private Scope scope() {
|
private Scope scope() {
|
||||||
final List<AppointmentWarehouseClient.Warehouse> catalog;
|
final List<AppointmentWarehouseClient.Warehouse> catalog;
|
||||||
|
|||||||
+8
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
|
|||||||
return ApiResponse.success(new Resolved(source,result.get(0)));
|
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")
|
@GetMapping("/cargo")
|
||||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||||
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ info:
|
|||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
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:
|
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
|
||||||
get:
|
get:
|
||||||
operationId: resolveOrderAppointmentSource
|
operationId: resolveOrderAppointmentSource
|
||||||
|
|||||||
+15
@@ -8,6 +8,21 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class AppointmentCargoServiceTest {
|
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() {
|
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
|
||||||
var mapper = mock(AppointmentCargoMapper.class);
|
var mapper = mock(AppointmentCargoMapper.class);
|
||||||
var warehouses = mock(AppointmentWarehouseClient.class);
|
var warehouses = mock(AppointmentWarehouseClient.class);
|
||||||
|
|||||||
+14
@@ -17,6 +17,20 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
class AppointmentPlanningAuthorizationTest {
|
class AppointmentPlanningAuthorizationTest {
|
||||||
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
|
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() {
|
@Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
|
||||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||||
var controller=context.getBean(AppointmentPlanningController.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 时必填")
|
@Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填")
|
||||||
@Min(1) Long senderAddressId,
|
@Min(1) Long senderAddressId,
|
||||||
@Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填")
|
@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,
|
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||||
String destinationCountryCode, Long destinationWarehouseId,
|
String destinationCountryCode, Long destinationWarehouseId,
|
||||||
@@ -98,7 +100,7 @@ public final class OrderRequests {
|
|||||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
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,
|
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||||
@@ -116,7 +118,7 @@ public final class OrderRequests {
|
|||||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
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,
|
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||||
@@ -133,7 +135,7 @@ public final class OrderRequests {
|
|||||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
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,
|
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||||
@@ -150,7 +152,7 @@ public final class OrderRequests {
|
|||||||
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
|
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
|
||||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||||
shipmentId, referenceId, null, null, null, null, null);
|
shipmentId, referenceId, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
|
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ public class OrderApplicationService {
|
|||||||
default -> false;
|
default -> false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// 保存客户下单资料;快递单号仅属于快递自提,切换方式时清空避免误带。
|
||||||
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
|
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
|
||||||
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses,
|
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses,
|
||||||
boolean allowLegacyInboundCode) {
|
boolean allowLegacyInboundCode) {
|
||||||
@@ -662,6 +663,10 @@ public class OrderApplicationService {
|
|||||||
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
|
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
|
||||||
order.setCustomerRemark(request.customerRemark());
|
order.setCustomerRemark(request.customerRemark());
|
||||||
order.setPickupMethod(request.pickupMethod());
|
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.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
|
||||||
order.setReceivingWarehouseId(request.receivingWarehouseId());
|
order.setReceivingWarehouseId(request.receivingWarehouseId());
|
||||||
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
|
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ public class PlatformLegacyOrderImportService {
|
|||||||
"买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND",
|
"买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND",
|
||||||
channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED",
|
channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED",
|
||||||
"LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType,
|
"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() {
|
private Customer fixedCustomer() {
|
||||||
|
|||||||
@@ -1451,6 +1451,7 @@ components:
|
|||||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||||
senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
|
senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
|
||||||
privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址}
|
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}
|
customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true}
|
||||||
cargoes:
|
cargoes:
|
||||||
type: array
|
type: array
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ class MerchantOrderCommandBindingTest {
|
|||||||
}).build();
|
}).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
|
@Test
|
||||||
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
|
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
|
||||||
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
|
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
|
||||||
|
|||||||
@@ -111,6 +111,58 @@ class OrderApplicationServiceStateTest {
|
|||||||
&& order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType())));
|
&& 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
|
@Test
|
||||||
void creatingDraftPreservesProductImageDimensionsAndRemark() {
|
void creatingDraftPreservesProductImageDimensionsAndRemark() {
|
||||||
OrderRepository repository = mock(OrderRepository.class);
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export async function run(context) {
|
|||||||
|
|
||||||
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
|
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
|
||||||
|
|
||||||
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
|
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。另外保留四票已测量、未领取未组托样本:同客户两票(2/1 箱)和跨客户两票(2/3 箱),逐票核对 Admin `WAIT_PALLETIZE` 列表可见,保存 `manualRecords` 和 `manualStage=WAIT_PALLETIZE_VISIBLE`。显式启用 `orders` 的 `orderFixtureOnly` 来源生产范围,保留审核收敛、重放与受理启动,默认订单全量验收仍覆盖申报修改;称重复用当前仓已有托盘,不要求固定编号。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。离线合同为 `dropship-mixed-palletization-contract.test.mjs`。
|
||||||
|
|
||||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8')
|
||||||
|
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('mixed pallet acceptance uses formal producers and fails on any failed preparation assertion', () => {
|
||||||
|
assert.match(source, /await createOrder\(context\)/)
|
||||||
|
assert.match(source, /await measure\(context\)/)
|
||||||
|
assert.match(source, /!context.steps.slice\(start\).some\(\(step\) => !step.passed\)/)
|
||||||
|
assert.match(source, /if \(!passed\) throw new Error/)
|
||||||
|
assert.match(source, /context.context.allowAnyExistingPallet = true/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retained manual fixtures cover same and different customers and remain visible and unclaimed', () => {
|
||||||
|
assert.match(source, /\[2, 1, 2, 3\].entries\(\)/)
|
||||||
|
assert.match(source, /index < 3 \? savedContext.merchantQualificationFixture : secondCustomer/)
|
||||||
|
assert.match(source, /index < 2 \? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER'/)
|
||||||
|
assert.match(source, /mainStatus=WAIT_PALLETIZE&inboundCode=/)
|
||||||
|
assert.match(source, /task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0/)
|
||||||
|
assert.match(source, /result.manualStage = 'WAIT_PALLETIZE_VISIBLE'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit order producer scope preserves approval convergence and default declaration acceptance', () => {
|
||||||
|
const branch = orders.indexOf('if (context.context.orderFixtureOnly === true)')
|
||||||
|
assert.ok(branch > orders.indexOf('const inboundResult = await waitForInbound'))
|
||||||
|
assert.ok(branch > orders.indexOf('await verifyOrderApprovalReplay(context'))
|
||||||
|
assert.ok(branch < orders.indexOf('const editableDetailResponse'))
|
||||||
|
assert.match(orders.slice(branch, orders.indexOf('const editableDetailResponse')), /await finishOrderLifecycle\(context/)
|
||||||
|
assert.match(orders, /申报修改保持订单号入仓码和渠道且版本递增/)
|
||||||
|
assert.match(orders, /申报修改异步同步到Firstleg入库事实/)
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ export async function run(context) {
|
|||||||
const savedContext = { ...context.context }
|
const savedContext = { ...context.context }
|
||||||
const originalManualCount = context.options.prepareDropshipManualCount
|
const originalManualCount = context.options.prepareDropshipManualCount
|
||||||
const records = []
|
const records = []
|
||||||
|
let secondCustomer = null
|
||||||
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
|
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
|
||||||
context.createdResources.dropshipMixedPalletization = result
|
context.createdResources.dropshipMixedPalletization = result
|
||||||
function check(name, response, passed, details = '') {
|
function check(name, response, passed, details = '') {
|
||||||
@@ -32,11 +33,13 @@ export async function run(context) {
|
|||||||
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
|
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
context.context.orderFixtureOnly = true
|
||||||
|
context.context.allowAnyExistingPallet = true
|
||||||
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
|
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
|
||||||
for (const [index, count] of [2, 3, 1].entries()) {
|
for (const [index, count] of [2, 3, 1].entries()) {
|
||||||
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
|
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
|
||||||
context.marker = `${originalMarker}-${label}`
|
context.marker = `${originalMarker}-${label}`
|
||||||
if (index === 1) await createCustomer(context, { publishEnvironment: false })
|
if (index === 1) { await createCustomer(context, { publishEnvironment: false }); secondCustomer = context.context.merchantQualificationFixture }
|
||||||
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
|
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
|
||||||
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||||
context.context.orderDestinationType = 'OVERSEAS'
|
context.context.orderDestinationType = 'OVERSEAS'
|
||||||
@@ -127,6 +130,33 @@ export async function run(context) {
|
|||||||
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
|
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
|
||||||
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
|
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
|
||||||
for (const record of records) record.task = await readTask(record)
|
for (const record of records) record.task = await readTask(record)
|
||||||
|
// 保留同客户两票和跨客户两票独立样本,均停在未领取、未组托状态。
|
||||||
|
result.manualRecords = []
|
||||||
|
for (const [index, count] of [2, 1, 2, 3].entries()) {
|
||||||
|
context.marker = `${originalMarker}-MANUAL-${index + 1}`
|
||||||
|
context.context.merchantQualificationFixture = index < 3 ? savedContext.merchantQualificationFixture : secondCustomer
|
||||||
|
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
|
||||||
|
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||||
|
context.context.orderDestinationType = 'OVERSEAS'
|
||||||
|
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
|
||||||
|
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
|
||||||
|
totalPackageCount: count, palletCount: 1, skuCodes: [`MANUAL-COMMON-${originalMarker}`.slice(-60), `MANUAL-SECOND-${originalMarker}`.slice(-60)] }
|
||||||
|
context.options.prepareDropshipManualCount = true
|
||||||
|
const start = context.steps.length
|
||||||
|
await createOrder(context)
|
||||||
|
await measure(context)
|
||||||
|
const source = context.createdResources.dropshipManualCount
|
||||||
|
check('人工打托样本正式下单测量通过', null, Boolean(source?.taskId) && !context.steps.slice(start).some((step) => !step.passed))
|
||||||
|
const order = { ...context.createdResources.orders }
|
||||||
|
const list = await command('回查一件代发打托列表可见', 'GET', `/api/platform/v1/orders?mainStatus=WAIT_PALLETIZE&inboundCode=${encodeURIComponent(source.inboundCode)}&page=1&size=20`)
|
||||||
|
const row = list.records?.find((item) => String(item.orderId) === order.orderId)
|
||||||
|
check('人工样本出现在用户的一件代发打托页签', null, Boolean(row) && row.mainStatus === 'WAIT_PALLETIZE')
|
||||||
|
const task = await readTask({ label: `MANUAL-${index + 1}`, source })
|
||||||
|
check('人工样本未领取未清点未归托', null, task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0)
|
||||||
|
result.manualRecords.push({ label: `MANUAL-${index + 1}`, group: index < 2 ? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER',
|
||||||
|
order, source: { ...source }, measurement: { ...context.createdResources.inboundMeasurement } })
|
||||||
|
}
|
||||||
|
result.manualStage = 'WAIT_PALLETIZE_VISIBLE'
|
||||||
} finally {
|
} finally {
|
||||||
context.marker = originalMarker; context.context = savedContext
|
context.marker = originalMarker; context.context = savedContext
|
||||||
context.options.prepareDropshipManualCount = originalManualCount
|
context.options.prepareDropshipManualCount = originalManualCount
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCES
|
|||||||
test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => {
|
test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => {
|
||||||
const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||||
const run = source.slice(source.indexOf('export async function run('))
|
const run = source.slice(source.indexOf('export async function run('))
|
||||||
const finish = run.indexOf('await finishOrderLifecycle(')
|
const finish = run.lastIndexOf('await finishOrderLifecycle(')
|
||||||
for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确',
|
for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确',
|
||||||
'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) {
|
'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) {
|
||||||
assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment)
|
assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment)
|
||||||
|
|||||||
@@ -1110,6 +1110,25 @@ export async function run(context) {
|
|||||||
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
|
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
|
||||||
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
|
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
|
||||||
|
|
||||||
|
// 专项造单仅生产已审核的真实入库来源;默认全量验收仍继续验证申报修改。
|
||||||
|
if (context.context.orderFixtureOnly === true) {
|
||||||
|
const inbound = inboundResult.detail
|
||||||
|
Object.assign(context.createdResources.orders, {
|
||||||
|
inboundId, inboundCode: inbound.summary.inboundCode,
|
||||||
|
customerRemark: inbound.publicRemark,
|
||||||
|
internalRemark: `${context.marker} Admin 订单链路验收`,
|
||||||
|
cargoRemark: payload.cargoes?.[0]?.cargoRemark || null,
|
||||||
|
processStatus: inbound.summary.processStatus, taskId: id(inboundResult.task.id),
|
||||||
|
fixtureOnly: true,
|
||||||
|
})
|
||||||
|
const source = { tenantId, inboundId, marker: context.marker }
|
||||||
|
context.createdResources.ocrInbound = source
|
||||||
|
context.createdResources.testInbound = source
|
||||||
|
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) context.context[key] = source
|
||||||
|
await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
|
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
|
||||||
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
|
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
|
||||||
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
|
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
|
||||||
|
|||||||
Reference in New Issue
Block a user