优化
This commit is contained in:
@@ -3,11 +3,15 @@ package com.baiyue.lastleg;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.lastleg.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.lastleg")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueLastlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** 将海外业务 Outbox 投递到国内区域事件桥。 */
|
||||
@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient",
|
||||
url = "${baiyue.firstleg.event-bridge-url:}")
|
||||
public interface EventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
+12
-5
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -48,7 +50,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||
@Operation(summary = "Page tail-leg order rows")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -57,7 +60,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<LastlegOrderDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -66,7 +70,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
|
||||
public ResponseEntity<byte[]> export(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -79,7 +84,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
|
||||
@Operation(summary = "Transfer one order to a destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<Integer> transferOne(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -89,7 +95,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/destination")
|
||||
@Operation(summary = "Transfer multiple orders to a destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<Integer> transferBatch(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
|
||||
+2
@@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/platform/v1/last-leg")
|
||||
@Tag(name = "Outbound and delivery")
|
||||
public class DeliveryTransferController {
|
||||
|
||||
+1
-8
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class DeliveryResponses {
|
||||
@@ -17,11 +16,5 @@ public final class DeliveryResponses {
|
||||
String overseasWarehouseId, String status, int palletCount,
|
||||
int packageCount, LocalDateTime createdAt) {}
|
||||
|
||||
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
|
||||
String orderRefs, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount) {}
|
||||
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
|
||||
PageResult<PalletRow> pallets) {}
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
|
||||
}
|
||||
|
||||
+1
-9
@@ -136,14 +136,7 @@ public class DeliveryTransferService {
|
||||
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
|
||||
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
|
||||
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
|
||||
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
|
||||
.orderByAsc(OutboundPalletItem::getId));
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
|
||||
page(page, page.getRecords().stream().map(this::palletRow).toList()));
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
@@ -206,7 +199,6 @@ public class DeliveryTransferService {
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
|
||||
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
|
||||
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -31,6 +32,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
@Tag(name = "Tail-leg vehicle dispatch")
|
||||
public class DispatchController {
|
||||
|
||||
+3
-1
@@ -7,13 +7,15 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
|
||||
public class DispatchPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private DispatchPdaWorkflowService service;
|
||||
|
||||
+5
-17
@@ -1,27 +1,15 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LastlegOrderDetailResponse(
|
||||
LastlegOrder order,
|
||||
LastlegOrderSummary order,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<LastlegOrderStatusHistory> statusHistory,
|
||||
List<DropshipPallet> dropshipPallets
|
||||
List<History> statusHistory
|
||||
) {
|
||||
public record DropshipPallet(
|
||||
Long profileId,
|
||||
String sourcePalletNo,
|
||||
Long warehousePalletId,
|
||||
String warehousePalletNo,
|
||||
Long inventoryPositionId,
|
||||
String status,
|
||||
List<DropshipSkuInventory> skuInventory,
|
||||
List<DropshipCarton> cartons
|
||||
) {}
|
||||
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ public record LastlegOrderSummary(
|
||||
Boolean detained,
|
||||
Integer palletCount,
|
||||
Integer packageCount,
|
||||
Integer pickedPackageCount,
|
||||
Integer shippedPackageCount,
|
||||
Integer remainingPackageCount,
|
||||
BigDecimal volumeCbm,
|
||||
Integer productQuantity,
|
||||
BigDecimal volumeWeightKg,
|
||||
@@ -36,8 +33,6 @@ public record LastlegOrderSummary(
|
||||
LocalDateTime actualArrivalAt,
|
||||
LocalDateTime estimatedWarehouseAt,
|
||||
String inboundOrderNo,
|
||||
String outboundOrderNo,
|
||||
String deliveryOrderNo,
|
||||
String relabelOrderNo,
|
||||
String relabelStatus,
|
||||
String palletMergeFlag,
|
||||
@@ -46,6 +41,7 @@ public record LastlegOrderSummary(
|
||||
String privateRemark,
|
||||
String customerRemark,
|
||||
String warehouseRemark,
|
||||
LocalDateTime createdAt
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public class LastlegEventOutbox {
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime lockedAt;
|
||||
private String lockedBy;
|
||||
private String lastError;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+25
-5
@@ -1,20 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -25,15 +30,27 @@ public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Page inbound plans without PDA, internal-event or outbound workflow fields", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<PageResult<InboundPlanSummary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InboundPlanRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@PostMapping("/convert")
|
||||
@Operation(summary = "Convert one inbound order to one inbound plan")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
@Operation(summary = "Convert one inbound order to one inbound plan", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get an inbound plan with lines, pallets and allocations", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
@@ -41,7 +58,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/{planNo}/pallets/supplement")
|
||||
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo,
|
||||
@Valid @RequestBody SupplementPalletRequest request) {
|
||||
@@ -49,7 +67,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/fba-appointments/batch")
|
||||
@Operation(summary = "Create one FBA appointment for selected inbound lines")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Create one FBA appointment for selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||
@@ -59,7 +78,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get an FBA appointment and its selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class InboundPlanRequests {
|
||||
private InboundPlanRequests() {
|
||||
}
|
||||
|
||||
public record Query(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String planNo,
|
||||
String inboundCode,
|
||||
String status,
|
||||
String overseasWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String fbaWarehouseCode,
|
||||
String appointmentStatus,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo
|
||||
) {
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded")
|
||||
public record InboundPlanSummary(
|
||||
Long id,
|
||||
String planNo,
|
||||
Long sourceOrderId,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String orderType,
|
||||
String inboundCode,
|
||||
String containerNo,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseName,
|
||||
String destinationWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
String fbaWarehouseCode,
|
||||
Integer packageCount,
|
||||
BigDecimal totalVolumeCbm,
|
||||
Integer palletCount,
|
||||
String status,
|
||||
String appointmentStatus,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
+37
@@ -1,13 +1,50 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " +
|
||||
"WHERE p.customer_id=#{customerId}" +
|
||||
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
|
||||
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
|
||||
"WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
|
||||
"WHERE ai.plan_id=p.id)</if>" +
|
||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</if>" +
|
||||
"<if test='q.createdTo != null'> AND p.created_at <=#{q.createdTo}</if>";
|
||||
|
||||
@Select("<script>SELECT p.id,p.plan_no,p.source_order_id,p.customer_id,o.customer_name,o.order_type," +
|
||||
"l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," +
|
||||
"l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," +
|
||||
"COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," +
|
||||
"COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.plan_id=p.id " +
|
||||
"ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" +
|
||||
PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InboundPlanSummary> pageRows(@Param("customerId") String customerId,
|
||||
@Param("q") InboundPlanRequests.Query query,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*)" + PAGE_FROM + "</script>")
|
||||
long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query);
|
||||
|
||||
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
|
||||
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets")
|
||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
|
||||
public class InboundPalletAssignmentController {
|
||||
@Resource private InboundPdaWorkflowService service;
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda")
|
||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
|
||||
public class InboundPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private InboundPdaWorkflowService service;
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ public class InboundStockReceipt {
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private String productNameEn;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
private Long warehouseLocationId;
|
||||
|
||||
+85
-5
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
@@ -13,6 +14,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
@@ -34,6 +36,7 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class InboundStockReceiptService {
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||
@Resource private InboundStockReceiptMapper receipts;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -79,9 +82,22 @@ public class InboundStockReceiptService {
|
||||
if (pickedPackages != value(line.getRequiredPackageCount())) {
|
||||
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||
}
|
||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
? List.of() : skuRemainders(order.getId(), pickedPackages);
|
||||
for (InboundPlanPalletAllocation allocation : lineAllocations) {
|
||||
created.add(persist(customerId, plan, task, line, allocation,
|
||||
requirePallet(palletById, allocation), order, receivedAt));
|
||||
InboundPlanPallet pallet = requirePallet(palletById, allocation);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
|
||||
SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt));
|
||||
} else {
|
||||
long allocationPackages = value(allocation.getPickedPackageCount());
|
||||
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
|
||||
created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
|
||||
throw conflict("Inbound SKU package snapshots were not fully received");
|
||||
}
|
||||
}
|
||||
activateDropship(customerId, task.getId(), orderById, created);
|
||||
@@ -112,20 +128,21 @@ public class InboundStockReceiptService {
|
||||
|
||||
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
|
||||
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
|
||||
LastlegOrder order, LocalDateTime receivedAt) {
|
||||
long packages = value(allocation.getPickedPackageCount());
|
||||
LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) {
|
||||
long packages = sku.packages();
|
||||
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
|
||||
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
|
||||
}
|
||||
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
|
||||
"Destination warehouse code is required for stock-in");
|
||||
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
|
||||
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
|
||||
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
|
||||
"Actual overseas warehouse is required for stock-in");
|
||||
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
|
||||
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
|
||||
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
|
||||
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
|
||||
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
|
||||
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
|
||||
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
|
||||
order.getSourceEventId(), operationKey,
|
||||
@@ -140,6 +157,9 @@ public class InboundStockReceiptService {
|
||||
receipt.setLineId(line.getId());
|
||||
receipt.setOrderId(order.getId());
|
||||
receipt.setInboundCode(line.getInboundCode());
|
||||
receipt.setSkuCode(sku.skuCode());
|
||||
receipt.setProductNameZh(sku.productNameZh());
|
||||
receipt.setProductNameEn(sku.productNameEn());
|
||||
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
|
||||
receipt.setPalletNo(pallet.getPalletNo());
|
||||
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
|
||||
@@ -154,6 +174,46 @@ public class InboundStockReceiptService {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private List<SkuRemainder> skuRemainders(Long orderId, long expectedPackages) {
|
||||
List<LastlegOrderDetail> details = orderDetails.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrderDetail>()
|
||||
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
|
||||
Map<String, SkuRemainder> grouped = new java.util.LinkedHashMap<>();
|
||||
for (LastlegOrderDetail detail : details) {
|
||||
String skuCode = trim(detail.getSkuCode());
|
||||
long packages = value(detail.getPackageCount());
|
||||
if (skuCode == null || packages <= 0) continue;
|
||||
SkuRemainder existing = grouped.get(skuCode);
|
||||
if (existing == null) {
|
||||
grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()),
|
||||
trim(detail.getProductNameEn()), packages));
|
||||
} else {
|
||||
existing.remaining += packages;
|
||||
}
|
||||
}
|
||||
long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum();
|
||||
if (grouped.isEmpty() || actualPackages != expectedPackages) {
|
||||
throw conflict("Inbound SKU package snapshots must exactly match the received package total");
|
||||
}
|
||||
return new java.util.ArrayList<>(grouped.values());
|
||||
}
|
||||
|
||||
private static List<SkuSnapshot> consume(List<SkuRemainder> remainders, long packages) {
|
||||
List<SkuSnapshot> result = new java.util.ArrayList<>();
|
||||
long pending = packages;
|
||||
for (SkuRemainder remainder : remainders) {
|
||||
if (pending == 0) break;
|
||||
if (remainder.remaining == 0) continue;
|
||||
long accepted = Math.min(pending, remainder.remaining);
|
||||
result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh,
|
||||
remainder.productNameEn, accepted));
|
||||
remainder.remaining -= accepted;
|
||||
pending -= accepted;
|
||||
}
|
||||
if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
|
||||
List<InboundStockReceipt> stockReceipts) {
|
||||
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
|
||||
@@ -190,4 +250,24 @@ public class InboundStockReceiptService {
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
private static final class SkuRemainder {
|
||||
private final String skuCode;
|
||||
private final String productNameZh;
|
||||
private final String productNameEn;
|
||||
private long remaining;
|
||||
|
||||
private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) {
|
||||
this.skuCode = skuCode;
|
||||
this.productNameZh = productNameZh;
|
||||
this.productNameEn = productNameEn;
|
||||
this.remaining = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {
|
||||
private static SkuSnapshot unspecified(long packages) {
|
||||
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.lastleg.client.EventBridgeClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class InboundUnloadCompletedOutboxRelay {
|
||||
@Resource private UnloadCompletedOutboxRepository repository;
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
|
||||
@Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10));
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) {
|
||||
if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue;
|
||||
LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId);
|
||||
if (row == null) continue;
|
||||
try {
|
||||
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
|
||||
EventEnvelopeValidator.requireValid(event);
|
||||
var response = client.receive(internalToken, event);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !event.eventId().equals(response.data().eventId())) {
|
||||
throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件");
|
||||
}
|
||||
repository.published(row.getId(), workerId, LocalDateTime.now());
|
||||
} catch (Exception exception) {
|
||||
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
|
||||
exception.getMessage());
|
||||
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -2,7 +2,10 @@ package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
@@ -36,6 +39,19 @@ public class InboundPlanService {
|
||||
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
|
||||
@Resource private InboundPlanOrderMapper orderMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<InboundPlanSummary> page(String customerId, InboundPlanRequests.Query query) {
|
||||
if (query.createdFrom() != null && query.createdTo() != null
|
||||
&& query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
|
||||
}
|
||||
long total = planMapper.countRows(customerId, query);
|
||||
List<InboundPlanSummary> records = total == 0 ? List.of()
|
||||
: planMapper.pageRows(customerId, query, Math.multiplyExact(query.page() - 1, query.size()), query.size());
|
||||
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
|
||||
return new PageResult<>(records, query.page(), query.size(), total, pages);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
|
||||
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
|
||||
|
||||
+10
-37
@@ -5,12 +5,14 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -21,12 +23,13 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/platform/v1/last-leg/inventory")
|
||||
@Tag(name = "Tail-leg inbound-order package inventory commands")
|
||||
public class InventoryCommandController {
|
||||
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
|
||||
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
|
||||
public static final String MANAGE_PERMISSION = OverseasPermissions.INVENTORY_EDIT;
|
||||
public static final String ADJUST_PERMISSION = OverseasPermissions.INVENTORY_ADJUST;
|
||||
@Resource private InventoryCommandService service;
|
||||
|
||||
@PostMapping("/hold")
|
||||
@Operation(summary = "Move available inbound-order packages to hold")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')")
|
||||
@Operation(summary = "Move available inbound-order packages to hold", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT)
|
||||
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
@@ -34,47 +37,17 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/release")
|
||||
@Operation(summary = "Release held inbound-order packages")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')")
|
||||
@Operation(summary = "Release held inbound-order packages", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT)
|
||||
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/allocate")
|
||||
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
|
||||
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/unallocate")
|
||||
@Operation(summary = "Cancel an outbound allocation")
|
||||
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/pick")
|
||||
@Operation(summary = "Confirm picked inbound-order packages")
|
||||
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/ship")
|
||||
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
|
||||
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/adjust")
|
||||
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_ADJUST + "')")
|
||||
@Operation(summary = "Adjust inbound-order package inventory after a physical count", description = "Required authority: " + OverseasPermissions.INVENTORY_ADJUST)
|
||||
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
|
||||
requirePermission(user, ADJUST_PERMISSION);
|
||||
|
||||
+3
@@ -22,6 +22,9 @@ public final class InventoryCommandRequests {
|
||||
@NotNull Long inboundOrderId,
|
||||
@NotBlank String inboundOrderNo,
|
||||
@NotBlank String inboundCode,
|
||||
String skuCode,
|
||||
String productNameZh,
|
||||
String productNameEn,
|
||||
@NotNull Long palletId,
|
||||
@NotBlank String palletNo,
|
||||
String batchNo,
|
||||
|
||||
+12
-2
@@ -175,8 +175,9 @@ public class InventoryCommandService {
|
||||
}
|
||||
|
||||
private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) {
|
||||
String skuCode = sku(request.skuCode());
|
||||
InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(),
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode);
|
||||
if (balance != null) return balance;
|
||||
balance = new InventoryBalance();
|
||||
balance.setId(IdWorker.getId());
|
||||
@@ -191,6 +192,9 @@ public class InventoryCommandService {
|
||||
balance.setInboundOrderId(request.inboundOrderId());
|
||||
balance.setInboundOrderNo(request.inboundOrderNo().trim());
|
||||
balance.setInboundCode(request.inboundCode().trim());
|
||||
balance.setSkuCode(skuCode);
|
||||
balance.setProductNameZh(trim(request.productNameZh()));
|
||||
balance.setProductNameEn(trim(request.productNameEn()));
|
||||
zero(balance);
|
||||
balance.setVersion(0L);
|
||||
balance.setCreatedAt(LocalDateTime.now());
|
||||
@@ -200,7 +204,7 @@ public class InventoryCommandService {
|
||||
return balance;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(),
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
@@ -314,6 +318,7 @@ public class InventoryCommandService {
|
||||
ledger.setPalletNo(position.getPalletNo());
|
||||
ledger.setBatchNo(position.getBatchNo());
|
||||
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
|
||||
ledger.setSkuCode(balance.getSkuCode());
|
||||
ledger.setBusinessType(businessType);
|
||||
ledger.setBusinessOrderId(balance.getInboundOrderId());
|
||||
ledger.setBusinessOrderNo(trim(businessOrderNo));
|
||||
@@ -434,6 +439,11 @@ public class InventoryCommandService {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static String sku(String value) {
|
||||
String normalized = trim(value);
|
||||
return normalized == null ? "UNSPECIFIED" : normalized;
|
||||
}
|
||||
|
||||
private static String remarkSuffix(String remark) {
|
||||
String value = trim(remark);
|
||||
return value == null ? "" : "; " + value;
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ public class InventoryBalance {
|
||||
private Long inboundOrderId;
|
||||
private String inboundOrderNo;
|
||||
private String inboundCode;
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private String productNameEn;
|
||||
private Long availablePackages;
|
||||
private Long holdPackages;
|
||||
private Long allocatedPackages;
|
||||
|
||||
+6
-4
@@ -12,22 +12,24 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
|
||||
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1")
|
||||
InventoryBalance findDimension(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("destinationWarehouseCode") String destinationWarehouseCode,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("skuCode") String skuCode);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
|
||||
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
|
||||
InventoryBalance lockDimension(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("destinationWarehouseCode") String destinationWarehouseCode,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("skuCode") String skuCode);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
|
||||
|
||||
+14
-6
@@ -9,6 +9,7 @@ import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -35,7 +37,8 @@ public class InventoryQueryController {
|
||||
@Resource private InventoryQueryService service;
|
||||
|
||||
@GetMapping(BASE_PATH + "/summary")
|
||||
@Operation(summary = "Get inbound-order package inventory totals")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<InventoryDashboardResponse> summary(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -44,7 +47,8 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances")
|
||||
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -53,7 +57,8 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
||||
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<List<InventoryPositionView>> positions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -63,7 +68,8 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/ledger")
|
||||
@Operation(summary = "Page append-only inventory movements")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -72,7 +78,8 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||
public ResponseEntity<byte[]> exportBalances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -81,7 +88,8 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||
public ResponseEntity<byte[]> exportLedgers(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ public record InventoryBalanceView(
|
||||
Long inboundOrderId,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String skuCode,
|
||||
String productNameZh,
|
||||
String productNameEn,
|
||||
long totalPackages,
|
||||
long availablePackages,
|
||||
long holdPackages,
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ public final class InventoryQueryRequests {
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String businessOrderNo,
|
||||
String skuCode,
|
||||
String productName,
|
||||
String inventoryStatus
|
||||
) {
|
||||
public BalanceQuery {
|
||||
|
||||
+4
@@ -19,6 +19,8 @@ public interface InventoryQueryMapper {
|
||||
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
|
||||
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
|
||||
"<if test='q.productName != null and q.productName != \"\"'> AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
|
||||
"<choose>" +
|
||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
@@ -37,6 +39,8 @@ public interface InventoryQueryMapper {
|
||||
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
|
||||
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
|
||||
"<if test='q.productName != null and q.productName != \"\"'> AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
|
||||
"<choose>" +
|
||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages > 0</when>" +
|
||||
|
||||
+4
-3
@@ -78,12 +78,12 @@ public class InventoryQueryService {
|
||||
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
|
||||
List<String> rows = new ArrayList<>();
|
||||
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
|
||||
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
|
||||
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
|
||||
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
|
||||
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
|
||||
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
|
||||
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
|
||||
csv(v.inboundOrderNo()), csv(v.inboundCode()),
|
||||
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
|
||||
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
|
||||
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
|
||||
csv(v.procurementPackages()))));
|
||||
@@ -116,7 +116,8 @@ public class InventoryQueryService {
|
||||
return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
|
||||
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
|
||||
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
|
||||
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
|
||||
value.getInboundOrderNo(), value.getInboundCode(), value.getSkuCode(), value.getProductNameZh(), value.getProductNameEn(),
|
||||
availablePackages + holdPackages + allocatedPackages + pickedPackages
|
||||
+ inTransitPackages + receivingPackages + procurementPackages,
|
||||
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
|
||||
receivingPackages, procurementPackages, value.getUpdatedAt());
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.looseboard;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.looseboard.mapper")
|
||||
public class LooseboardMapperConfiguration {
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.baiyue.lastleg.looseboard.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
||||
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/local-looseboard-orders")
|
||||
public class PlatformLocalLooseboardController {
|
||||
@Resource private LocalLooseboardService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')")
|
||||
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.Query query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_CREATE + "')")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @Valid @RequestBody LocalLooseboardRequests.Create request) {
|
||||
requirePermission(user, OverseasPermissions.LOOSEBOARD_CREATE);
|
||||
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
|
||||
}
|
||||
|
||||
@PutMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_EDIT + "')")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody LocalLooseboardRequests.Update request) {
|
||||
requirePermission(user, OverseasPermissions.LOOSEBOARD_EDIT);
|
||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_STATUS + "')")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody LocalLooseboardRequests.ChangeStatus request) {
|
||||
requirePermission(user, OverseasPermissions.LOOSEBOARD_STATUS);
|
||||
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePermission(AuthenticatedUser user, String permission) {
|
||||
requirePlatform(user);
|
||||
if (user.permissions() == null || !user.permissions().contains(permission)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredCustomer(String customerId) {
|
||||
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
|
||||
}
|
||||
return customerId.trim();
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.lastleg.looseboard.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class LocalLooseboardRequests {
|
||||
private LocalLooseboardRequests() { }
|
||||
|
||||
public record Create(
|
||||
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
|
||||
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
|
||||
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
|
||||
@NotBlank @Size(max = 255) String origin,
|
||||
@Size(max = 500) String originAddress,
|
||||
@NotBlank @Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Size(max = 32) String containerType,
|
||||
@NotNull @Min(0) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||
@DecimalMin("0.00") BigDecimal budgetAmount,
|
||||
@Size(min = 3, max = 3) String currency,
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 128) String contactInfo,
|
||||
LocalDate requestedDate,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Update(
|
||||
@NotNull @Min(0) Long version,
|
||||
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
|
||||
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
|
||||
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
|
||||
@NotBlank @Size(max = 255) String origin,
|
||||
@Size(max = 500) String originAddress,
|
||||
@NotBlank @Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Size(max = 32) String containerType,
|
||||
@NotNull @Min(0) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||
@DecimalMin("0.00") BigDecimal budgetAmount,
|
||||
@Size(min = 3, max = 3) String currency,
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 128) String contactInfo,
|
||||
LocalDate requestedDate,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record Query(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String customerId,
|
||||
String orderNo,
|
||||
String routeKeyword,
|
||||
String status,
|
||||
String transportMode,
|
||||
LocalDate requestedFrom,
|
||||
LocalDate requestedTo) { }
|
||||
|
||||
public record ChangeStatus(
|
||||
@NotNull @Min(0) Long version,
|
||||
@NotBlank @Size(max = 32) String status,
|
||||
@Size(max = 1000) String remark) { }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.looseboard.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class LocalLooseboardResponses {
|
||||
private LocalLooseboardResponses() { }
|
||||
|
||||
public record Summary(
|
||||
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
|
||||
String transportType, String origin, String destination, String containerType, int packageCount,
|
||||
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
|
||||
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt) { }
|
||||
|
||||
public record History(
|
||||
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||
String changeDetail, String remark, LocalDateTime createdAt) { }
|
||||
|
||||
public record Detail(
|
||||
Summary summary, String originAddress, String destinationAddress, String remark,
|
||||
String createdBy, List<History> histories) { }
|
||||
}
|
||||
+8
-9
@@ -1,4 +1,4 @@
|
||||
package com.baiyue.lastleg.procurement.entity;
|
||||
package com.baiyue.lastleg.looseboard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@@ -8,17 +8,16 @@ import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_procurement_outbound")
|
||||
public class OverseasProcurementOutbound {
|
||||
@TableName("ll_local_looseboard_history")
|
||||
public class LocalLooseboardHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long orderId;
|
||||
private String customerId;
|
||||
private Long quantity;
|
||||
private String outboundNo;
|
||||
private String carrier;
|
||||
private String trackingNo;
|
||||
private LocalDateTime outboundAt;
|
||||
private String operationType;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String operatorId;
|
||||
private String changeDetail;
|
||||
private String remark;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.looseboard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_local_looseboard_order")
|
||||
public class LocalLooseboardOrder {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private String orderNo;
|
||||
private String transportMode;
|
||||
private String palletRequired;
|
||||
private String transportType;
|
||||
private String origin;
|
||||
private String originAddress;
|
||||
private String destination;
|
||||
private String destinationAddress;
|
||||
private String containerType;
|
||||
private Integer packageCount;
|
||||
private BigDecimal totalWeightKg;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private BigDecimal budgetAmount;
|
||||
private String currency;
|
||||
private String contactName;
|
||||
private String contactInfo;
|
||||
private LocalDate requestedDate;
|
||||
private String status;
|
||||
private String remark;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.looseboard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LocalLooseboardHistoryMapper extends BaseMapper<LocalLooseboardHistory> {
|
||||
@Select("SELECT * FROM ll_local_looseboard_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
|
||||
List<LocalLooseboardHistory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.looseboard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
|
||||
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
|
||||
LocalLooseboardOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
|
||||
|
||||
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
LocalLooseboardOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE")
|
||||
LocalLooseboardOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
package com.baiyue.lastleg.looseboard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LocalLooseboardService {
|
||||
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
|
||||
public static final String DISPATCHED = "DISPATCHED";
|
||||
public static final String IN_TRANSIT = "IN_TRANSIT";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED);
|
||||
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
|
||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
|
||||
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
|
||||
IN_TRANSIT, Set.of(COMPLETED));
|
||||
|
||||
@Resource private LocalLooseboardOrderMapper orders;
|
||||
@Resource private LocalLooseboardHistoryMapper histories;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||
LocalLooseboardRequests.Create request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
LocalLooseboardOrder replay = orders.findKey(customerId, key);
|
||||
if (replay != null) return replay(customerId, replay, requestHash);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalLooseboardOrder order = new LocalLooseboardOrder();
|
||||
order.setId(IdWorker.getId());
|
||||
order.setCustomerId(customerId);
|
||||
order.setOrderNo("LB" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
|
||||
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
|
||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
|
||||
order.setStatus(PENDING_ACCEPTANCE);
|
||||
order.setIdempotencyKey(key);
|
||||
order.setRequestHash(requestHash);
|
||||
order.setCreatedBy(operatorId);
|
||||
order.setVersion(0L);
|
||||
order.setCreatedAt(now);
|
||||
order.setUpdatedAt(now);
|
||||
try {
|
||||
orders.insert(order);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
|
||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||
throw exception;
|
||||
}
|
||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
|
||||
return detail(customerId, order.getOrderNo());
|
||||
}
|
||||
|
||||
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
|
||||
validateQuery(query);
|
||||
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
|
||||
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
||||
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
||||
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
||||
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
||||
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
||||
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
|
||||
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
|
||||
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
|
||||
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
|
||||
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
|
||||
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
|
||||
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
|
||||
result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
|
||||
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
return response(order);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Update request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
|
||||
throw conflict("Only pending-acceptance looseboard orders can be edited");
|
||||
}
|
||||
Map<String, Object> before = snapshot(order);
|
||||
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
|
||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
|
||||
Map<String, Object> after = snapshot(order);
|
||||
if (before.equals(after)) return response(order);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||
changeDetail(before, after), null, now);
|
||||
return response(order);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.ChangeStatus request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
String next = norm(request.status());
|
||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
||||
String from = order.getStatus();
|
||||
if (next.equals(from)) return response(order);
|
||||
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
|
||||
throw conflict("Unsupported looseboard order transition: " + from + " -> " + next);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setStatus(next);
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
|
||||
return response(order);
|
||||
}
|
||||
|
||||
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
|
||||
String origin, String originAddress, String destination, String destinationAddress,
|
||||
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
|
||||
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
|
||||
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
|
||||
order.setTransportMode(norm(mode));
|
||||
order.setPalletRequired(norm(palletRequired));
|
||||
order.setTransportType(norm(transportType));
|
||||
order.setOrigin(origin.trim());
|
||||
order.setOriginAddress(trim(originAddress));
|
||||
order.setDestination(destination.trim());
|
||||
order.setDestinationAddress(trim(destinationAddress));
|
||||
order.setContainerType(trim(containerType));
|
||||
order.setPackageCount(packageCount);
|
||||
order.setTotalWeightKg(totalWeightKg);
|
||||
order.setTotalVolumeCbm(totalVolumeCbm);
|
||||
order.setBudgetAmount(budgetAmount);
|
||||
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
|
||||
order.setContactName(trim(contactName));
|
||||
order.setContactInfo(trim(contactInfo));
|
||||
order.setRequestedDate(requestedDate);
|
||||
order.setRemark(trim(remark));
|
||||
}
|
||||
|
||||
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
|
||||
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
|
||||
return detail(customerId, order.getOrderNo());
|
||||
}
|
||||
|
||||
private LocalLooseboardOrder lock(String customerId, String orderNo) {
|
||||
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
return order;
|
||||
}
|
||||
|
||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
||||
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
|
||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||
histories.listOrder(order.getCustomerId(), order.getId()).stream()
|
||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
|
||||
value.getCreatedAt())).toList());
|
||||
}
|
||||
|
||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
|
||||
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
|
||||
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
|
||||
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
|
||||
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
|
||||
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
|
||||
}
|
||||
|
||||
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
|
||||
String changeDetail, String remark, LocalDateTime now) {
|
||||
LocalLooseboardHistory history = new LocalLooseboardHistory();
|
||||
history.setId(IdWorker.getId());
|
||||
history.setOrderId(order.getId());
|
||||
history.setCustomerId(order.getCustomerId());
|
||||
history.setOperationType(type);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setOperatorId(operator);
|
||||
history.setChangeDetail(changeDetail);
|
||||
history.setRemark(trim(remark));
|
||||
history.setCreatedAt(now);
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private void validateQuery(LocalLooseboardRequests.Query query) {
|
||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
|
||||
throw badRequest("Unsupported looseboard order status");
|
||||
}
|
||||
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
|
||||
throw badRequest("Unsupported looseboard transport mode");
|
||||
}
|
||||
if (query.requestedFrom() != null && query.requestedTo() != null
|
||||
&& query.requestedFrom().isAfter(query.requestedTo())) {
|
||||
throw badRequest("requestedFrom must not be after requestedTo");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("transportMode", order.getTransportMode());
|
||||
value.put("palletRequired", order.getPalletRequired());
|
||||
value.put("transportType", order.getTransportType());
|
||||
value.put("origin", order.getOrigin());
|
||||
value.put("originAddress", order.getOriginAddress());
|
||||
value.put("destination", order.getDestination());
|
||||
value.put("destinationAddress", order.getDestinationAddress());
|
||||
value.put("containerType", order.getContainerType());
|
||||
value.put("packageCount", order.getPackageCount());
|
||||
value.put("totalWeightKg", order.getTotalWeightKg());
|
||||
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
|
||||
value.put("budgetAmount", order.getBudgetAmount());
|
||||
value.put("currency", order.getCurrency());
|
||||
value.put("contactName", order.getContactName());
|
||||
value.put("contactInfo", order.getContactInfo());
|
||||
value.put("requestedDate", order.getRequestedDate());
|
||||
value.put("remark", order.getRemark());
|
||||
return value;
|
||||
}
|
||||
|
||||
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
|
||||
Map<String, Object> changes = new LinkedHashMap<>();
|
||||
before.forEach((field, oldValue) -> {
|
||||
Object newValue = after.get(field);
|
||||
if (!Objects.equals(oldValue, newValue)) {
|
||||
Map<String, Object> pair = new LinkedHashMap<>();
|
||||
pair.put("before", oldValue);
|
||||
pair.put("after", newValue);
|
||||
changes.put(field, pair);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return objectMapper.writeValueAsString(changes);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot serialize looseboard change detail", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsBytes(value)));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Cannot hash looseboard request", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
|
||||
order.setVersion(value(order.getVersion()) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String norm(String value) { return value.trim().toUpperCase(); }
|
||||
private static ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
private static ResponseStatusException notFound() {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, "Local looseboard order not found");
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/exchange-rates")
|
||||
@Tag(name = "Monthly exchange rates / 月度汇率")
|
||||
public class PlatformMonthlyExchangeRateController {
|
||||
@Resource private MonthlyExchangeRateService service;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
@Operation(summary = "Page monthly exchange rates", description = "Required authority: platform:overseas-master-data:exchange-rate:list")
|
||||
public ApiResponse<PageResult<MonthlyExchangeRateDtos.Response>> page(@Valid MonthlyExchangeRateDtos.Query query) {
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
@GetMapping("/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Summary> summary() { return ApiResponse.success(service.summary()); }
|
||||
@GetMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> detail(@PathVariable String month) { return ApiResponse.success(service.detail(month)); }
|
||||
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_CREATE + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody MonthlyExchangeRateDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
|
||||
@PutMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EDIT + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.Update request) {
|
||||
return ApiResponse.success(service.update(month, operator(user), request));
|
||||
}
|
||||
@PatchMapping("/{month}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_STATUS + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
|
||||
return ApiResponse.success(service.changeStatus(month, operator(user), request));
|
||||
}
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
|
||||
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class MonthlyExchangeRateDtos {
|
||||
private MonthlyExchangeRateDtos() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthFrom,
|
||||
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthTo,
|
||||
String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
public record Create(@NotBlank @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String month,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
|
||||
@Size(max = 1000) String remark) { }
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
|
||||
@Size(max = 1000) String remark) { }
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion,
|
||||
@Size(max = 500) String remark) { }
|
||||
public record Response(String month, String baseCurrency, String quoteCurrency,
|
||||
BigDecimal usdToCnyAverage, String status, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt, String remark) { }
|
||||
public record Summary(String latestMonth, BigDecimal latestUsdToCnyAverage,
|
||||
String updatedBy, LocalDateTime updatedAt) { }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_monthly_exchange_rate")
|
||||
public class MonthlyExchangeRate {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String rateMonth;
|
||||
private String baseCurrency;
|
||||
private String quoteCurrency;
|
||||
private BigDecimal usdToCnyAverage;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_monthly_exchange_rate_history")
|
||||
public class MonthlyExchangeRateHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long exchangeRateId;
|
||||
private BigDecimal beforeRate;
|
||||
private BigDecimal afterRate;
|
||||
private String beforeStatus;
|
||||
private String afterStatus;
|
||||
private String operatorId;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MonthlyExchangeRateHistoryMapper extends BaseMapper<MonthlyExchangeRateHistory> { }
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface MonthlyExchangeRateMapper extends BaseMapper<MonthlyExchangeRate> {
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1")
|
||||
MonthlyExchangeRate findMonth(@Param("month") String month);
|
||||
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1 FOR UPDATE")
|
||||
MonthlyExchangeRate lockMonth(@Param("month") String month);
|
||||
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE status='ACTIVE' ORDER BY rate_month DESC LIMIT 1")
|
||||
MonthlyExchangeRate latestActive();
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class MonthlyExchangeRateService {
|
||||
private static final String ACTIVE = "ACTIVE";
|
||||
private static final String INACTIVE = "INACTIVE";
|
||||
@Resource private MonthlyExchangeRateMapper rates;
|
||||
@Resource private MonthlyExchangeRateHistoryMapper histories;
|
||||
|
||||
public PageResult<MonthlyExchangeRateDtos.Response> page(MonthlyExchangeRateDtos.Query query) {
|
||||
validateRange(query.monthFrom(), query.monthTo());
|
||||
String status = optionalStatus(query.status());
|
||||
Page<MonthlyExchangeRate> page = rates.selectPage(Page.of(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<MonthlyExchangeRate>()
|
||||
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
|
||||
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
|
||||
.eq(status != null, MonthlyExchangeRate::getStatus, status)
|
||||
.orderByDesc(MonthlyExchangeRate::getRateMonth));
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public MonthlyExchangeRateDtos.Response detail(String month) { return response(required(month)); }
|
||||
|
||||
public MonthlyExchangeRateDtos.Summary summary() {
|
||||
MonthlyExchangeRate value = rates.latestActive();
|
||||
return value == null ? new MonthlyExchangeRateDtos.Summary(null, null, null, null)
|
||||
: new MonthlyExchangeRateDtos.Summary(value.getRateMonth(), value.getUsdToCnyAverage(),
|
||||
value.getUpdatedBy(), value.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response create(String operator, MonthlyExchangeRateDtos.Create request) {
|
||||
String month = month(request.month()); LocalDateTime now = LocalDateTime.now();
|
||||
MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setId(IdWorker.getId());
|
||||
value.setRateMonth(month); value.setBaseCurrency("USD"); value.setQuoteCurrency("CNY");
|
||||
value.setUsdToCnyAverage(request.usdToCnyAverage()); value.setStatus(ACTIVE);
|
||||
value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
|
||||
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
try { rates.insert(value); }
|
||||
catch (DuplicateKeyException exception) { throw conflict("该月份汇率已存在"); }
|
||||
history(value.getId(), null, value.getUsdToCnyAverage(), null, ACTIVE, operator, request.remark());
|
||||
return detail(month);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response update(String month, String operator,
|
||||
MonthlyExchangeRateDtos.Update request) {
|
||||
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
|
||||
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
|
||||
.eq(MonthlyExchangeRate::getId, current.getId())
|
||||
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
|
||||
.set(MonthlyExchangeRate::getUsdToCnyAverage, request.usdToCnyAverage())
|
||||
.set(MonthlyExchangeRate::getRemark, trim(request.remark()))
|
||||
.set(MonthlyExchangeRate::getUpdatedBy, operator).set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
history(current.getId(), current.getUsdToCnyAverage(), request.usdToCnyAverage(),
|
||||
current.getStatus(), current.getStatus(), operator, request.remark());
|
||||
return detail(current.getRateMonth());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response changeStatus(String month, String operator,
|
||||
MonthlyExchangeRateDtos.ChangeStatus request) {
|
||||
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
|
||||
String status = status(request.status());
|
||||
if (status.equals(current.getStatus())) return response(current);
|
||||
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
|
||||
.eq(MonthlyExchangeRate::getId, current.getId())
|
||||
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
|
||||
.set(MonthlyExchangeRate::getStatus, status).set(MonthlyExchangeRate::getUpdatedBy, operator)
|
||||
.set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
history(current.getId(), current.getUsdToCnyAverage(), current.getUsdToCnyAverage(),
|
||||
current.getStatus(), status, operator, request.remark());
|
||||
return detail(current.getRateMonth());
|
||||
}
|
||||
|
||||
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
|
||||
validateRange(query.monthFrom(), query.monthTo());
|
||||
String status = optionalStatus(query.status());
|
||||
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
|
||||
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
|
||||
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
|
||||
.eq(status != null, MonthlyExchangeRate::getStatus, status)
|
||||
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
|
||||
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
|
||||
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
|
||||
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
|
||||
String beforeStatus, String afterStatus, String operator, String remark) {
|
||||
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
|
||||
value.setExchangeRateId(id); value.setBeforeRate(beforeRate); value.setAfterRate(afterRate);
|
||||
value.setBeforeStatus(beforeStatus); value.setAfterStatus(afterStatus); value.setOperatorId(operator);
|
||||
value.setRemark(trim(remark)); value.setCreatedAt(LocalDateTime.now()); histories.insert(value);
|
||||
}
|
||||
private MonthlyExchangeRate required(String month) {
|
||||
MonthlyExchangeRate value = rates.findMonth(month(month));
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
|
||||
}
|
||||
private MonthlyExchangeRate locked(String month) {
|
||||
MonthlyExchangeRate value = rates.lockMonth(month(month));
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
|
||||
}
|
||||
private MonthlyExchangeRateDtos.Response response(MonthlyExchangeRate value) {
|
||||
return new MonthlyExchangeRateDtos.Response(value.getRateMonth(), value.getBaseCurrency(),
|
||||
value.getQuoteCurrency(), value.getUsdToCnyAverage(), value.getStatus(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
|
||||
}
|
||||
private void requireVersion(MonthlyExchangeRate value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private void validateRange(String from, String to) {
|
||||
if (StringUtils.hasText(from) && StringUtils.hasText(to) && from.compareTo(to) > 0) throw badRequest("monthFrom不能晚于monthTo");
|
||||
}
|
||||
private String month(String value) {
|
||||
if (value == null || !value.matches("\\d{4}-(0[1-9]|1[0-2])")) throw badRequest("月份格式必须为yyyy-MM"); return value;
|
||||
}
|
||||
private String optionalStatus(String value) { return StringUtils.hasText(value) ? status(value) : null; }
|
||||
private String status(String value) {
|
||||
String result = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!ACTIVE.equals(result) && !INACTIVE.equals(result)) throw badRequest("汇率状态只支持ACTIVE/INACTIVE"); return result;
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "domesticPartnerWarehouseClient",
|
||||
url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface DomesticPartnerWarehouseClient {
|
||||
@GetMapping("/internal/warehouses/overseas-partners")
|
||||
ApiResponse<List<Option>> activePartners(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken);
|
||||
|
||||
record Option(Long id, String warehouseCode, String warehouseName, String countryCode) { }
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/overseas-quotes")
|
||||
@Tag(name = "Overseas warehouse quotes / 海外仓报价")
|
||||
public class PlatformOverseasQuoteController {
|
||||
@Resource private OverseasQuoteService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "Page overseas warehouse quotes",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list")
|
||||
public ApiResponse<PageResult<OverseasQuoteResponses.Summary>> page(
|
||||
@Valid OverseasQuoteRequests.Query query) {
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "Get overseas warehouse quote detail",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> detail(@PathVariable long id) {
|
||||
return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/warehouse-options")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "List cached active overseas partner warehouse options",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs")
|
||||
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
|
||||
return ApiResponse.success(service.warehouseOptions());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_CREATE + "')")
|
||||
@Operation(summary = "Create an inactive overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:create")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> create(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody OverseasQuoteRequests.Create request) {
|
||||
return ApiResponse.success(service.create(operator(user), request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EDIT + "')")
|
||||
@Operation(summary = "Update an inactive overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:edit")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody OverseasQuoteRequests.Update request) {
|
||||
return ApiResponse.success(service.update(id, operator(user), request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_STATUS + "')")
|
||||
@Operation(summary = "Enable or disable an overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:status")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> status(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody OverseasQuoteRequests.ChangeStatus request) {
|
||||
return ApiResponse.success(service.changeStatus(id, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered overseas warehouse quotes",
|
||||
description = "Required authority: platform:overseas-master-data:quote:export")
|
||||
public ResponseEntity<byte[]> export(@Valid OverseasQuoteRequests.Query query) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN";
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class OverseasQuoteRequests {
|
||||
private OverseasQuoteRequests() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
String keyword, String warehouseId, String providerName,
|
||||
String currency, String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
|
||||
public record Fee(@NotBlank @Size(max = 128) String item,
|
||||
@NotBlank @Size(max = 64) String billingUnit,
|
||||
@NotNull @DecimalMin("0") BigDecimal unitPrice,
|
||||
@DecimalMin("0") BigDecimal minimumCharge,
|
||||
@Size(max = 500) String remark) { }
|
||||
|
||||
public record TransitPrice(@NotBlank @Size(max = 64) String fbaWarehouseCode,
|
||||
@NotBlank @Size(max = 64) String deliveryMethod,
|
||||
@DecimalMin("0") BigDecimal estimatedMiles,
|
||||
@NotNull @DecimalMin("0") BigDecimal price,
|
||||
@DecimalMin("0") BigDecimal minimumCharge,
|
||||
@Size(max = 500) String remark) { }
|
||||
|
||||
public record Create(@NotBlank @Size(max = 128) String name,
|
||||
@NotBlank String warehouseId,
|
||||
@NotBlank @Size(max = 128) String providerName,
|
||||
@NotBlank @Size(max = 3) String currency,
|
||||
@NotNull LocalDate effectiveStart,
|
||||
@NotNull LocalDate effectiveEnd,
|
||||
@NotBlank @Size(max = 64) String versionCode,
|
||||
@NotEmpty List<@Valid Fee> warehouseFees,
|
||||
List<@Valid TransitPrice> transitPrices,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(max = 128) String name,
|
||||
@NotBlank @Size(max = 128) String providerName,
|
||||
@NotBlank @Size(max = 3) String currency,
|
||||
@NotNull LocalDate effectiveStart,
|
||||
@NotNull LocalDate effectiveEnd,
|
||||
@NotEmpty List<@Valid Fee> warehouseFees,
|
||||
List<@Valid TransitPrice> transitPrices,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class OverseasQuoteResponses {
|
||||
private OverseasQuoteResponses() { }
|
||||
|
||||
public record PartnerWarehouseOption(String warehouseId, String warehouseCode,
|
||||
String warehouseName, String countryCode) { }
|
||||
|
||||
public record Summary(String id, String name, String warehouseId, String warehouseCode,
|
||||
String warehouseName, String countryCode, String providerName,
|
||||
String currency, LocalDate effectiveStart, LocalDate effectiveEnd,
|
||||
String versionCode, String status, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt, String remark) { }
|
||||
|
||||
public record Fee(String item, String billingUnit, BigDecimal unitPrice,
|
||||
BigDecimal minimumCharge, String remark) { }
|
||||
|
||||
public record TransitPrice(String fbaWarehouseCode, String deliveryMethod,
|
||||
BigDecimal estimatedMiles, BigDecimal price,
|
||||
BigDecimal minimumCharge, String remark) { }
|
||||
|
||||
public record Detail(Summary summary, List<Fee> warehouseFees,
|
||||
List<TransitPrice> transitPrices,
|
||||
String createdBy, LocalDateTime createdAt) { }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote")
|
||||
public class OverseasQuote {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String name;
|
||||
private Long warehouseId;
|
||||
private String warehouseCode;
|
||||
private String warehouseName;
|
||||
private String countryCode;
|
||||
private String providerName;
|
||||
private String currency;
|
||||
private LocalDate effectiveStart;
|
||||
private LocalDate effectiveEnd;
|
||||
private String versionCode;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote_fee")
|
||||
public class OverseasQuoteFee {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long quoteId;
|
||||
private Integer sequenceNo;
|
||||
private String item;
|
||||
private String billingUnit;
|
||||
private BigDecimal unitPrice;
|
||||
private BigDecimal minimumCharge;
|
||||
private String remark;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote_transit_price")
|
||||
public class OverseasQuoteTransitPrice {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long quoteId;
|
||||
private Integer sequenceNo;
|
||||
private String fbaWarehouseCode;
|
||||
private String deliveryMethod;
|
||||
private BigDecimal estimatedMiles;
|
||||
private BigDecimal price;
|
||||
private BigDecimal minimumCharge;
|
||||
private String remark;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteFeeMapper extends BaseMapper<OverseasQuoteFee> { }
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteMapper extends BaseMapper<OverseasQuote> {
|
||||
@Select("SELECT * FROM ll_overseas_quote WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
OverseasQuote lockById(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_overseas_quote
|
||||
WHERE warehouse_id=#{warehouseId} AND provider_name=#{providerName} AND currency=#{currency}
|
||||
AND status='ACTIVE' AND id<>#{excludedId}
|
||||
AND effective_start<=#{effectiveEnd} AND effective_end>=#{effectiveStart}
|
||||
""")
|
||||
int countActiveOverlap(@Param("warehouseId") long warehouseId,
|
||||
@Param("providerName") String providerName,
|
||||
@Param("currency") String currency,
|
||||
@Param("effectiveStart") LocalDate effectiveStart,
|
||||
@Param("effectiveEnd") LocalDate effectiveEnd,
|
||||
@Param("excludedId") long excludedId);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteTransitPriceMapper extends BaseMapper<OverseasQuoteTransitPrice> { }
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteFeeMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteTransitPriceMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class OverseasQuoteService {
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
public static final String INACTIVE = "INACTIVE";
|
||||
@Resource private OverseasQuoteMapper quotes;
|
||||
@Resource private OverseasQuoteFeeMapper fees;
|
||||
@Resource private OverseasQuoteTransitPriceMapper transitPrices;
|
||||
@Resource private PartnerWarehouseDirectory warehouses;
|
||||
|
||||
public PageResult<OverseasQuoteResponses.Summary> page(OverseasQuoteRequests.Query query) {
|
||||
Long warehouseId = parseOptionalId(query.warehouseId());
|
||||
String status = normalizeOptionalStatus(query.status());
|
||||
var wrapper = new LambdaQueryWrapper<OverseasQuote>()
|
||||
.and(StringUtils.hasText(query.keyword()), value -> value
|
||||
.like(OverseasQuote::getName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getWarehouseName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getProviderName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getVersionCode, query.keyword().trim()))
|
||||
.eq(warehouseId != null, OverseasQuote::getWarehouseId, warehouseId)
|
||||
.like(StringUtils.hasText(query.providerName()), OverseasQuote::getProviderName,
|
||||
StringUtils.hasText(query.providerName()) ? query.providerName().trim() : null)
|
||||
.eq(StringUtils.hasText(query.currency()), OverseasQuote::getCurrency,
|
||||
StringUtils.hasText(query.currency()) ? query.currency().trim().toUpperCase(Locale.ROOT) : null)
|
||||
.eq(status != null, OverseasQuote::getStatus, status)
|
||||
.orderByDesc(OverseasQuote::getUpdatedAt).orderByDesc(OverseasQuote::getId);
|
||||
Page<OverseasQuote> page = quotes.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public OverseasQuoteResponses.Detail detail(long id) {
|
||||
OverseasQuote quote = required(id);
|
||||
List<OverseasQuoteResponses.Fee> feeRows = fees.selectList(new LambdaQueryWrapper<OverseasQuoteFee>()
|
||||
.eq(OverseasQuoteFee::getQuoteId, id).orderByAsc(OverseasQuoteFee::getSequenceNo))
|
||||
.stream().map(value -> new OverseasQuoteResponses.Fee(value.getItem(), value.getBillingUnit(),
|
||||
value.getUnitPrice(), value.getMinimumCharge(), value.getRemark())).toList();
|
||||
List<OverseasQuoteResponses.TransitPrice> transitRows = transitPrices.selectList(
|
||||
new LambdaQueryWrapper<OverseasQuoteTransitPrice>().eq(OverseasQuoteTransitPrice::getQuoteId, id)
|
||||
.orderByAsc(OverseasQuoteTransitPrice::getSequenceNo))
|
||||
.stream().map(value -> new OverseasQuoteResponses.TransitPrice(value.getFbaWarehouseCode(),
|
||||
value.getDeliveryMethod(), value.getEstimatedMiles(), value.getPrice(),
|
||||
value.getMinimumCharge(), value.getRemark())).toList();
|
||||
return new OverseasQuoteResponses.Detail(summary(quote), feeRows, transitRows,
|
||||
quote.getCreatedBy(), quote.getCreatedAt());
|
||||
}
|
||||
|
||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
|
||||
return warehouses.options();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail create(String operator, OverseasQuoteRequests.Create request) {
|
||||
validateDates(request.effectiveStart(), request.effectiveEnd());
|
||||
long warehouseId = parseId(request.warehouseId());
|
||||
var warehouse = warehouses.required(warehouseId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OverseasQuote quote = new OverseasQuote();
|
||||
quote.setId(IdWorker.getId()); quote.setName(request.name().trim()); quote.setWarehouseId(warehouseId);
|
||||
quote.setWarehouseCode(warehouse.warehouseCode()); quote.setWarehouseName(warehouse.warehouseName());
|
||||
quote.setCountryCode(warehouse.countryCode()); quote.setProviderName(request.providerName().trim());
|
||||
quote.setCurrency(currency(request.currency())); quote.setEffectiveStart(request.effectiveStart());
|
||||
quote.setEffectiveEnd(request.effectiveEnd()); quote.setVersionCode(request.versionCode().trim());
|
||||
quote.setStatus(INACTIVE); quote.setRemark(trim(request.remark())); quote.setRecordVersion(0L);
|
||||
quote.setCreatedBy(operator); quote.setUpdatedBy(operator); quote.setCreatedAt(now); quote.setUpdatedAt(now);
|
||||
try { quotes.insert(quote); }
|
||||
catch (DuplicateKeyException exception) { throw conflict("同一合作仓、报价方和版本号已存在"); }
|
||||
replaceChildren(quote.getId(), request.warehouseFees(), request.transitPrices());
|
||||
return detail(quote.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail update(long id, String operator, OverseasQuoteRequests.Update request) {
|
||||
validateDates(request.effectiveStart(), request.effectiveEnd());
|
||||
OverseasQuote current = locked(id);
|
||||
requireVersion(current, request.recordVersion());
|
||||
if (ACTIVE.equals(current.getStatus())) throw conflict("启用中的报价不能直接编辑,请先停用");
|
||||
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
|
||||
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
|
||||
.set(OverseasQuote::getName, request.name().trim())
|
||||
.set(OverseasQuote::getProviderName, request.providerName().trim())
|
||||
.set(OverseasQuote::getCurrency, currency(request.currency()))
|
||||
.set(OverseasQuote::getEffectiveStart, request.effectiveStart())
|
||||
.set(OverseasQuote::getEffectiveEnd, request.effectiveEnd())
|
||||
.set(OverseasQuote::getRemark, trim(request.remark()))
|
||||
.set(OverseasQuote::getUpdatedBy, operator).set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
replaceChildren(id, request.warehouseFees(), request.transitPrices());
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail changeStatus(long id, String operator,
|
||||
OverseasQuoteRequests.ChangeStatus request) {
|
||||
OverseasQuote current = locked(id);
|
||||
requireVersion(current, request.recordVersion());
|
||||
String status = normalizeStatus(request.status());
|
||||
if (status.equals(current.getStatus())) return detail(id);
|
||||
if (ACTIVE.equals(status) && quotes.countActiveOverlap(current.getWarehouseId(), current.getProviderName(),
|
||||
current.getCurrency(), current.getEffectiveStart(), current.getEffectiveEnd(), id) > 0) {
|
||||
throw conflict("同一合作仓、报价方和币种存在生效区间重叠的启用报价");
|
||||
}
|
||||
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
|
||||
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
|
||||
.set(OverseasQuote::getStatus, status).set(OverseasQuote::getUpdatedBy, operator)
|
||||
.set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(OverseasQuoteRequests.Query query) {
|
||||
OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(),
|
||||
query.warehouseId(), query.providerName(), query.currency(), query.status());
|
||||
StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n");
|
||||
for (var row : page(exportQuery).records()) {
|
||||
csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',')
|
||||
.append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',')
|
||||
.append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',')
|
||||
.append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',')
|
||||
.append(cell(row.versionCode())).append(',').append(row.status()).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
}
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void replaceChildren(long quoteId, List<OverseasQuoteRequests.Fee> feeRows,
|
||||
List<OverseasQuoteRequests.TransitPrice> transitRows) {
|
||||
fees.delete(new LambdaQueryWrapper<OverseasQuoteFee>().eq(OverseasQuoteFee::getQuoteId, quoteId));
|
||||
transitPrices.delete(new LambdaQueryWrapper<OverseasQuoteTransitPrice>()
|
||||
.eq(OverseasQuoteTransitPrice::getQuoteId, quoteId));
|
||||
int sequence = 1;
|
||||
for (var row : feeRows) {
|
||||
OverseasQuoteFee value = new OverseasQuoteFee(); value.setId(IdWorker.getId()); value.setQuoteId(quoteId);
|
||||
value.setSequenceNo(sequence++); value.setItem(row.item().trim());
|
||||
value.setBillingUnit(row.billingUnit().trim()); value.setUnitPrice(row.unitPrice());
|
||||
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
|
||||
fees.insert(value);
|
||||
}
|
||||
sequence = 1;
|
||||
for (var row : transitRows == null ? List.<OverseasQuoteRequests.TransitPrice>of() : transitRows) {
|
||||
OverseasQuoteTransitPrice value = new OverseasQuoteTransitPrice(); value.setId(IdWorker.getId());
|
||||
value.setQuoteId(quoteId); value.setSequenceNo(sequence++);
|
||||
value.setFbaWarehouseCode(row.fbaWarehouseCode().trim().toUpperCase(Locale.ROOT));
|
||||
value.setDeliveryMethod(row.deliveryMethod().trim().toUpperCase(Locale.ROOT));
|
||||
value.setEstimatedMiles(zeroIfNull(row.estimatedMiles())); value.setPrice(row.price());
|
||||
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
|
||||
transitPrices.insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
private OverseasQuoteResponses.Summary summary(OverseasQuote value) {
|
||||
return new OverseasQuoteResponses.Summary(value.getId().toString(), value.getName(),
|
||||
value.getWarehouseId().toString(), value.getWarehouseCode(), value.getWarehouseName(),
|
||||
value.getCountryCode(), value.getProviderName(), value.getCurrency(), value.getEffectiveStart(),
|
||||
value.getEffectiveEnd(), value.getVersionCode(), value.getStatus(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
|
||||
}
|
||||
|
||||
private OverseasQuote required(long id) {
|
||||
OverseasQuote value = quotes.selectById(id);
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
|
||||
return value;
|
||||
}
|
||||
private OverseasQuote locked(long id) {
|
||||
OverseasQuote value = quotes.lockById(id);
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
|
||||
return value;
|
||||
}
|
||||
private void requireVersion(OverseasQuote value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private void validateDates(java.time.LocalDate start, java.time.LocalDate end) {
|
||||
if (end.isBefore(start)) throw badRequest("报价结束日期不能早于开始日期");
|
||||
}
|
||||
private Long parseOptionalId(String value) { return StringUtils.hasText(value) ? parseId(value) : null; }
|
||||
private long parseId(String value) {
|
||||
try { long id = Long.parseLong(value.trim()); if (id <= 0) throw new NumberFormatException(); return id; }
|
||||
catch (NumberFormatException exception) { throw badRequest("warehouseId 无效"); }
|
||||
}
|
||||
private String normalizeOptionalStatus(String value) { return StringUtils.hasText(value) ? normalizeStatus(value) : null; }
|
||||
private String normalizeStatus(String value) {
|
||||
String status = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!ACTIVE.equals(status) && !INACTIVE.equals(status)) throw badRequest("报价状态只支持 ACTIVE/INACTIVE");
|
||||
return status;
|
||||
}
|
||||
private String currency(String value) {
|
||||
String currency = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (currency.length() != 3) throw badRequest("币种必须为三位代码");
|
||||
return currency;
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.service;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Single-key immutable snapshot cache for active domestic overseas-partner warehouses. */
|
||||
@Component
|
||||
public class PartnerWarehouseDirectory {
|
||||
@Resource private DomesticPartnerWarehouseClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl;
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
|
||||
Snapshot current = snapshot;
|
||||
Instant now = clock.instant();
|
||||
if (current != null && now.isBefore(current.expiresAt())) return current.options();
|
||||
synchronized (this) {
|
||||
current = snapshot;
|
||||
now = clock.instant();
|
||||
if (current != null && now.isBefore(current.expiresAt())) return current.options();
|
||||
var response = client.activePartners(internalToken);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"国内海外合作仓候选暂不可用");
|
||||
}
|
||||
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
|
||||
.filter(value -> value != null && value.id() != null)
|
||||
.map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(),
|
||||
value.warehouseCode(), value.warehouseName(), value.countryCode()))
|
||||
.toList();
|
||||
snapshot = new Snapshot(options, now.plus(ttl));
|
||||
return snapshot.options();
|
||||
}
|
||||
}
|
||||
|
||||
public OverseasQuoteResponses.PartnerWarehouseOption required(long warehouseId) {
|
||||
return options().stream().filter(value -> Long.toString(warehouseId).equals(value.warehouseId()))
|
||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"所选海外合作仓不存在或已停用"));
|
||||
}
|
||||
|
||||
private record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
|
||||
private Snapshot { options = List.copyOf(options); }
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
|
||||
@Tag(name = "Vehicle master data / 车辆主数据")
|
||||
public class PlatformVehicleController {
|
||||
@Resource private VehicleService service;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
|
||||
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
|
||||
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
|
||||
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
|
||||
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
|
||||
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
|
||||
|
||||
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
|
||||
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
|
||||
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
|
||||
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
|
||||
|
||||
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
|
||||
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
|
||||
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Digits;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class VehicleDtos {
|
||||
private VehicleDtos() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Size(max = 128) String keyword, String ownership, String region, String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
|
||||
public record Create(@NotBlank @Size(max = 64) String plateNumber,
|
||||
@NotBlank @Size(max = 128) String model,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
|
||||
@NotNull @Min(0) Long mileageKm,
|
||||
@Size(max = 128) String driverOrDispatchCompany,
|
||||
@Size(max = 64) String phone,
|
||||
@NotBlank String region, @NotBlank String ownership,
|
||||
@NotBlank String status, @Size(max = 1000) String remark) { }
|
||||
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(max = 64) String plateNumber,
|
||||
@NotBlank @Size(max = 128) String model,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
|
||||
@NotNull @Min(0) Long mileageKm,
|
||||
@Size(max = 128) String driverOrDispatchCompany,
|
||||
@Size(max = 64) String phone,
|
||||
@NotBlank String region, @NotBlank String ownership,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
|
||||
|
||||
public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
|
||||
long mileageKm, String driverOrDispatchCompany, String phone, String region,
|
||||
String ownership, String status, String remark, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt) { }
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_vehicle")
|
||||
public class Vehicle {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String plateNumber;
|
||||
private String model;
|
||||
private BigDecimal fuelConsumptionPer100Km;
|
||||
private Long mileageKm;
|
||||
private String driverOrDispatchCompany;
|
||||
private String phone;
|
||||
private String region;
|
||||
private String ownership;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface VehicleMapper extends BaseMapper<Vehicle> {
|
||||
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
Vehicle lockById(@Param("id") long id);
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class VehicleService {
|
||||
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
|
||||
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
|
||||
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
|
||||
@Resource private VehicleMapper vehicles;
|
||||
|
||||
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
|
||||
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public VehicleDtos.Response detail(long id) { return response(required(id)); }
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
|
||||
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
|
||||
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
|
||||
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
|
||||
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
|
||||
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
|
||||
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
|
||||
return detail(value.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
|
||||
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
|
||||
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
|
||||
int updated;
|
||||
try {
|
||||
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
|
||||
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
|
||||
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
|
||||
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
|
||||
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
|
||||
.set(Vehicle::getMileageKm, request.mileageKm())
|
||||
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
|
||||
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
|
||||
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
|
||||
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
|
||||
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
|
||||
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
|
||||
String status = status(request.status());
|
||||
if (status.equals(current.getStatus())) return response(current);
|
||||
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
|
||||
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
|
||||
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
|
||||
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(VehicleDtos.Query query) {
|
||||
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
|
||||
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
|
||||
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
|
||||
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
|
||||
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
|
||||
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
|
||||
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
|
||||
return new LambdaQueryWrapper<Vehicle>()
|
||||
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
|
||||
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
|
||||
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
|
||||
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
|
||||
}
|
||||
private Vehicle required(long id) {
|
||||
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
|
||||
}
|
||||
private Vehicle locked(long id) {
|
||||
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
|
||||
}
|
||||
private VehicleDtos.Response response(Vehicle value) {
|
||||
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
|
||||
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
|
||||
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt());
|
||||
}
|
||||
private void requireVersion(Vehicle value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
|
||||
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
|
||||
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
|
||||
private static String optional(String value, Set<String> allowed, String label) {
|
||||
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
|
||||
}
|
||||
private static String required(String value, Set<String> allowed, String label) {
|
||||
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
|
||||
String normalized = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
|
||||
}
|
||||
private static String requiredText(String value, String label) {
|
||||
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+34
-9
@@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -20,12 +23,16 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
|
||||
public class PlatformOverseasProcurementController {
|
||||
@Resource private OverseasProcurementService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||
@Operation(summary = "Page overseas procurement orders", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
|
||||
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -34,7 +41,29 @@ public class PlatformOverseasProcurementController {
|
||||
return ApiResponse.success(service.page(trim(customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/customer-options")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||
@Operation(summary = "List procurement customer options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
|
||||
public ApiResponse<List<OverseasProcurementResponses.CustomerOption>> customerOptions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.customerOptions(keyword, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/warehouse-options")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||
@Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
|
||||
public ApiResponse<List<OverseasProcurementResponses.WarehouseOption>> warehouseOptions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.warehouseOptions());
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||
@Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo) {
|
||||
@@ -43,6 +72,8 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/review")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_REVIEW + "')")
|
||||
@Operation(summary = "Review an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_REVIEW)
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> review(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
|
||||
@@ -51,19 +82,13 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
|
||||
@Operation(summary = "Update an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_EDIT)
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
|
||||
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/outbounds")
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) {
|
||||
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
|
||||
+11
-18
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.lastleg.procurement.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
@@ -41,23 +42,15 @@ public final class OverseasProcurementRequests {
|
||||
@NotNull @Min(0) Long version,
|
||||
@Size(max = 128) String purchaseCategory,
|
||||
@Size(max = 255) String productName,
|
||||
@Size(max = 500) String productSpecification,
|
||||
@Size(max = 2000) String productUrl,
|
||||
JsonNode productSpecification,
|
||||
JsonNode productUrl,
|
||||
@Min(0) Long inboundQuantity,
|
||||
@Size(max = 128) String purchaseWarehouseId,
|
||||
@Size(max = 255) String purchaseWarehouseName,
|
||||
@Size(max = 128) String domesticCarrier,
|
||||
@Size(max = 128) String domesticTrackingNo,
|
||||
@Size(max = 128) String overseasCarrier,
|
||||
@Size(max = 128) String overseasTrackingNo,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record AddOutbound(
|
||||
@NotNull @Positive Long quantity,
|
||||
@NotBlank @Size(max = 128) String outboundNo,
|
||||
@Size(max = 128) String carrier,
|
||||
@Size(max = 128) String trackingNo,
|
||||
LocalDateTime outboundAt,
|
||||
@Size(max = 1000) String remark) {}
|
||||
JsonNode purchaseWarehouseId,
|
||||
JsonNode purchaseWarehouseName,
|
||||
JsonNode domesticCarrier,
|
||||
JsonNode domesticTrackingNo,
|
||||
JsonNode overseasCarrier,
|
||||
JsonNode overseasTrackingNo,
|
||||
JsonNode estimatedArrivalAt,
|
||||
JsonNode remark) {}
|
||||
}
|
||||
|
||||
+8
-6
@@ -12,15 +12,17 @@ public final class OverseasProcurementResponses {
|
||||
String purchaseCategory, String productName, String productSpecification,
|
||||
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
|
||||
String purchaseWarehouseId, String purchaseWarehouseName,
|
||||
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
|
||||
long inboundQuantity, long inventoryQuantity,
|
||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
|
||||
public record Outbound(
|
||||
Long id, long quantity, String outboundNo, String carrier, String trackingNo,
|
||||
LocalDateTime outboundAt, String remark, String createdBy, LocalDateTime createdAt) {}
|
||||
public record CustomerOption(String customerId, String customerName) {}
|
||||
|
||||
public record WarehouseOption(
|
||||
String warehouseId, String warehouseCode, String warehouseName, String countryCode) {}
|
||||
|
||||
public record History(
|
||||
String fromStatus, String toStatus, String operatorId, String remark,
|
||||
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||
String changeDetail, String remark,
|
||||
LocalDateTime createdAt) {}
|
||||
|
||||
public record Detail(
|
||||
@@ -30,5 +32,5 @@ public final class OverseasProcurementResponses {
|
||||
String overseasCarrier, String overseasTrackingNo,
|
||||
LocalDateTime estimatedArrivalAt, String remark,
|
||||
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
|
||||
List<Outbound> outbounds, List<History> histories) {}
|
||||
List<History> histories) {}
|
||||
}
|
||||
|
||||
-1
@@ -28,7 +28,6 @@ public class OverseasProcurementOrder {
|
||||
private String purchaseWarehouseId;
|
||||
private String purchaseWarehouseName;
|
||||
private Long inboundQuantity;
|
||||
private Long outboundQuantity;
|
||||
private String domesticCarrier;
|
||||
private String domesticTrackingNo;
|
||||
private String overseasCarrier;
|
||||
|
||||
+2
@@ -13,9 +13,11 @@ public class OverseasProcurementStatusHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long orderId;
|
||||
private String customerId;
|
||||
private String operationType;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String operatorId;
|
||||
private String changeDetail;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+8
-5
@@ -5,7 +5,8 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcurementOrder> {
|
||||
@@ -18,8 +19,10 @@ public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcu
|
||||
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE")
|
||||
OverseasProcurementOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
|
||||
|
||||
@Update("UPDATE ll_overseas_procurement_order SET outbound_quantity=outbound_quantity+#{quantity},version=version+1," +
|
||||
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND customer_id=#{customerId} " +
|
||||
"AND inbound_quantity-outbound_quantity>=#{quantity}")
|
||||
int addOutbound(@Param("id") Long id, @Param("customerId") String customerId, @Param("quantity") long quantity);
|
||||
@Select("<script>SELECT customer_id,MAX(customer_name) AS customer_name " +
|
||||
"FROM ll_overseas_procurement_order " +
|
||||
"<if test='keyword != null and keyword != ""'>" +
|
||||
"WHERE customer_id LIKE CONCAT('%',#{keyword},'%') OR customer_name LIKE CONCAT('%',#{keyword},'%')" +
|
||||
"</if> GROUP BY customer_id ORDER BY MAX(customer_name),customer_id LIMIT #{limit}</script>")
|
||||
List<OverseasProcurementOrder> customerOptions(@Param("keyword") String keyword, @Param("limit") int limit);
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.lastleg.procurement.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasProcurementOutboundMapper extends BaseMapper<OverseasProcurementOutbound> {
|
||||
@Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC")
|
||||
List<OverseasProcurementOutbound> listOrder(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
}
|
||||
+139
-68
@@ -4,15 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -26,21 +27,26 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class OverseasProcurementService {
|
||||
public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review";
|
||||
public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage";
|
||||
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
|
||||
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String APPROVED = "APPROVED";
|
||||
public static final String REJECTED = "REJECTED";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
|
||||
|
||||
@Resource private OverseasProcurementOrderMapper orders;
|
||||
@Resource private OverseasProcurementOutboundMapper outbounds;
|
||||
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
||||
@Resource private PartnerWarehouseDirectory warehouseDirectory;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
@@ -68,7 +74,6 @@ public class OverseasProcurementService {
|
||||
order.setContactName(request.contactName().trim());
|
||||
order.setContactPhone(request.contactPhone().trim());
|
||||
order.setInboundQuantity(0L);
|
||||
order.setOutboundQuantity(0L);
|
||||
order.setStatus(PENDING_REVIEW);
|
||||
order.setRemark(trim(request.remark()));
|
||||
order.setIdempotencyKey(key);
|
||||
@@ -84,10 +89,25 @@ public class OverseasProcurementService {
|
||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||
throw exception;
|
||||
}
|
||||
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
|
||||
history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now);
|
||||
return detail(customerId, order.getOrderNo());
|
||||
}
|
||||
|
||||
public List<OverseasProcurementResponses.CustomerOption> customerOptions(String keyword, int limit) {
|
||||
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
|
||||
return orders.customerOptions(trim(keyword), limit).stream()
|
||||
.map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(),
|
||||
Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
|
||||
return warehouseDirectory.options().stream()
|
||||
.map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(),
|
||||
value.warehouseCode(), value.warehouseName(), value.countryCode()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public PageResult<OverseasProcurementResponses.Summary> page(
|
||||
String customerId, OverseasProcurementRequests.Query query) {
|
||||
validateQuery(query);
|
||||
@@ -122,10 +142,10 @@ public class OverseasProcurementService {
|
||||
order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(),
|
||||
order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(),
|
||||
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
|
||||
outbounds.listOrder(customerId, order.getId()).stream().map(this::outbound).toList(),
|
||||
histories.listOrder(customerId, order.getId()).stream().map(value ->
|
||||
new OverseasProcurementResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList());
|
||||
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
|
||||
value.getCreatedAt())).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -146,64 +166,38 @@ public class OverseasProcurementService {
|
||||
order.setReviewedAt(now);
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
history(order, PENDING_REVIEW, next, operatorId, request.remark(), now);
|
||||
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now);
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.Detail update(String customerId, String orderNo,
|
||||
public OverseasProcurementResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||
OverseasProcurementRequests.Update request) {
|
||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
|
||||
Map<String, Object> before = editableSnapshot(order);
|
||||
if (request.inboundQuantity() != null) {
|
||||
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock");
|
||||
if (request.inboundQuantity() < value(order.getOutboundQuantity())) {
|
||||
throw conflict("Inbound quantity cannot be lower than the recorded outbound quantity");
|
||||
}
|
||||
order.setInboundQuantity(request.inboundQuantity());
|
||||
}
|
||||
setIfText(request.purchaseCategory(), order::setPurchaseCategory);
|
||||
setIfText(request.productName(), order::setProductName);
|
||||
setIfPresent(request.productSpecification(), order::setProductSpecification);
|
||||
setIfPresent(request.productUrl(), order::setProductUrl);
|
||||
setIfPresent(request.purchaseWarehouseId(), order::setPurchaseWarehouseId);
|
||||
setIfPresent(request.purchaseWarehouseName(), order::setPurchaseWarehouseName);
|
||||
setIfPresent(request.domesticCarrier(), order::setDomesticCarrier);
|
||||
setIfPresent(request.domesticTrackingNo(), order::setDomesticTrackingNo);
|
||||
setIfPresent(request.overseasCarrier(), order::setOverseasCarrier);
|
||||
setIfPresent(request.overseasTrackingNo(), order::setOverseasTrackingNo);
|
||||
if (request.estimatedArrivalAt() != null) order.setEstimatedArrivalAt(request.estimatedArrivalAt());
|
||||
setIfPresent(request.remark(), order::setRemark);
|
||||
touch(order, LocalDateTime.now());
|
||||
patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification);
|
||||
patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl);
|
||||
patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName());
|
||||
patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier);
|
||||
patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo);
|
||||
patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier);
|
||||
patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo);
|
||||
patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt);
|
||||
patchText(request.remark(), 1000, "remark", order::setRemark);
|
||||
Map<String, Object> after = editableSnapshot(order);
|
||||
if (before.equals(after)) return detail(customerId, orderNo);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.Detail addOutbound(String customerId, String orderNo, String operatorId,
|
||||
OverseasProcurementRequests.AddOutbound request) {
|
||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can ship stock");
|
||||
if (orders.addOutbound(order.getId(), customerId, request.quantity()) != 1) {
|
||||
throw conflict("Outbound quantity exceeds current procurement inventory");
|
||||
}
|
||||
OverseasProcurementOutbound outbound = new OverseasProcurementOutbound();
|
||||
outbound.setId(IdWorker.getId());
|
||||
outbound.setOrderId(order.getId());
|
||||
outbound.setCustomerId(customerId);
|
||||
outbound.setQuantity(request.quantity());
|
||||
outbound.setOutboundNo(request.outboundNo().trim());
|
||||
outbound.setCarrier(trim(request.carrier()));
|
||||
outbound.setTrackingNo(trim(request.trackingNo()));
|
||||
outbound.setOutboundAt(request.outboundAt() == null ? LocalDateTime.now() : request.outboundAt());
|
||||
outbound.setRemark(trim(request.remark()));
|
||||
outbound.setCreatedBy(operatorId);
|
||||
outbound.setCreatedAt(LocalDateTime.now());
|
||||
try {
|
||||
outbounds.insert(outbound);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("Outbound number already exists for this procurement order");
|
||||
}
|
||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||
changeDetail(before, after), null, now);
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
@@ -222,29 +216,24 @@ public class OverseasProcurementService {
|
||||
|
||||
private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) {
|
||||
long inbound = value(order.getInboundQuantity());
|
||||
long outbound = value(order.getOutboundQuantity());
|
||||
return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(),
|
||||
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
|
||||
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
|
||||
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound,
|
||||
inbound - outbound, order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
|
||||
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound,
|
||||
order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
|
||||
}
|
||||
|
||||
private OverseasProcurementResponses.Outbound outbound(OverseasProcurementOutbound value) {
|
||||
return new OverseasProcurementResponses.Outbound(value.getId(), value(value.getQuantity()), value.getOutboundNo(),
|
||||
value.getCarrier(), value.getTrackingNo(), value.getOutboundAt(), value.getRemark(), value.getCreatedBy(),
|
||||
value.getCreatedAt());
|
||||
}
|
||||
|
||||
private void history(OverseasProcurementOrder order, String from, String to, String operator,
|
||||
String remark, LocalDateTime now) {
|
||||
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
|
||||
String operator, String changeDetail, String remark, LocalDateTime now) {
|
||||
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
|
||||
history.setId(IdWorker.getId());
|
||||
history.setOrderId(order.getId());
|
||||
history.setCustomerId(order.getCustomerId());
|
||||
history.setOperationType(operationType);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setOperatorId(operator);
|
||||
history.setChangeDetail(changeDetail);
|
||||
history.setRemark(trim(remark));
|
||||
history.setCreatedAt(now);
|
||||
histories.insert(history);
|
||||
@@ -267,6 +256,91 @@ public class OverseasProcurementService {
|
||||
}
|
||||
}
|
||||
|
||||
private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) {
|
||||
if (warehouseId == null) {
|
||||
if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId");
|
||||
return;
|
||||
}
|
||||
String id = nullableText(warehouseId, 128, "purchaseWarehouseId");
|
||||
if (id == null) {
|
||||
order.setPurchaseWarehouseId(null);
|
||||
order.setPurchaseWarehouseName(null);
|
||||
return;
|
||||
}
|
||||
long numericId;
|
||||
try {
|
||||
numericId = Long.parseLong(id);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
|
||||
}
|
||||
var option = warehouseDirectory.required(numericId);
|
||||
order.setPurchaseWarehouseId(option.warehouseId());
|
||||
order.setPurchaseWarehouseName(option.warehouseName());
|
||||
}
|
||||
|
||||
private static void patchText(JsonNode node, int maxLength, String field,
|
||||
java.util.function.Consumer<String> setter) {
|
||||
if (node != null) setter.accept(nullableText(node, maxLength, field));
|
||||
}
|
||||
|
||||
private static String nullableText(JsonNode node, int maxLength, String field) {
|
||||
if (node.isNull()) return null;
|
||||
if (!node.isTextual()) throw badRequest(field + " must be a string or null");
|
||||
String value = trim(node.textValue());
|
||||
if (value != null && value.length() > maxLength) throw badRequest(field + " exceeds max length " + maxLength);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void patchDateTime(JsonNode node, java.util.function.Consumer<LocalDateTime> setter) {
|
||||
if (node == null) return;
|
||||
String value = nullableText(node, 64, "estimatedArrivalAt");
|
||||
if (value == null) {
|
||||
setter.accept(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setter.accept(LocalDateTime.parse(value));
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw badRequest("estimatedArrivalAt must be an ISO local date-time or null");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> editableSnapshot(OverseasProcurementOrder order) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("purchaseCategory", order.getPurchaseCategory());
|
||||
result.put("productName", order.getProductName());
|
||||
result.put("productSpecification", order.getProductSpecification());
|
||||
result.put("productUrl", order.getProductUrl());
|
||||
result.put("inboundQuantity", value(order.getInboundQuantity()));
|
||||
result.put("purchaseWarehouseId", order.getPurchaseWarehouseId());
|
||||
result.put("purchaseWarehouseName", order.getPurchaseWarehouseName());
|
||||
result.put("domesticCarrier", order.getDomesticCarrier());
|
||||
result.put("domesticTrackingNo", order.getDomesticTrackingNo());
|
||||
result.put("overseasCarrier", order.getOverseasCarrier());
|
||||
result.put("overseasTrackingNo", order.getOverseasTrackingNo());
|
||||
result.put("estimatedArrivalAt", order.getEstimatedArrivalAt());
|
||||
result.put("remark", order.getRemark());
|
||||
return result;
|
||||
}
|
||||
|
||||
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
|
||||
Map<String, Object> changes = new LinkedHashMap<>();
|
||||
before.forEach((field, oldValue) -> {
|
||||
Object newValue = after.get(field);
|
||||
if (!Objects.equals(oldValue, newValue)) {
|
||||
Map<String, Object> pair = new LinkedHashMap<>();
|
||||
pair.put("before", oldValue);
|
||||
pair.put("after", newValue);
|
||||
changes.put(field, pair);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return objectMapper.writeValueAsString(changes);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot serialize procurement change detail", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void touch(OverseasProcurementOrder order, LocalDateTime now) {
|
||||
order.setVersion(value(order.getVersion()) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
@@ -278,9 +352,6 @@ public class OverseasProcurementService {
|
||||
private static void setIfText(String value, java.util.function.Consumer<String> setter) {
|
||||
if (StringUtils.hasText(value)) setter.accept(value.trim());
|
||||
}
|
||||
private static void setIfPresent(String value, java.util.function.Consumer<String> setter) {
|
||||
if (value != null) setter.accept(trim(value));
|
||||
}
|
||||
private static ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
+11
@@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -19,21 +22,29 @@ public class RelabelApplicationController {
|
||||
@Resource private RelabelApplicationService service;
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
|
||||
@Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE)
|
||||
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
@Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
|
||||
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
@GetMapping("/{relabelNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
|
||||
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
|
||||
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
|
||||
}
|
||||
@PutMapping("/{relabelNo}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
|
||||
@Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
|
||||
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
|
||||
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class UnloadCompletedOutboxRepository {
|
||||
@Resource private LastlegEventOutboxMapper mapper;
|
||||
|
||||
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
|
||||
.or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
|
||||
.orderByAsc(LastlegEventOutbox::getCreatedAt).orderByAsc(LastlegEventOutbox::getId)
|
||||
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
|
||||
}
|
||||
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id)
|
||||
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.set(LastlegEventOutbox::getLockedBy, worker)
|
||||
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
}
|
||||
|
||||
public LastlegEventOutbox claimed(long id, String worker) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id)
|
||||
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
|
||||
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.eq(LastlegEventOutbox::getLockedBy, worker));
|
||||
}
|
||||
|
||||
public void published(long id, String worker, LocalDateTime now) {
|
||||
mapper.update(null, claimedBase(id, worker)
|
||||
.set(LastlegEventOutbox::getStatus, "PUBLISHED")
|
||||
.set(LastlegEventOutbox::getPublishedAt, now)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
.set(LastlegEventOutbox::getLockedBy, null)
|
||||
.set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, null));
|
||||
}
|
||||
|
||||
public void retry(long id, String worker, LocalDateTime retryAt, String error) {
|
||||
mapper.update(null, claimedBase(id, worker)
|
||||
.setSql("retry_count = retry_count + 1")
|
||||
.set(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.set(LastlegEventOutbox::getNextRetryAt, retryAt)
|
||||
.set(LastlegEventOutbox::getLockedBy, null)
|
||||
.set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
|
||||
private LambdaUpdateWrapper<LastlegEventOutbox> claimedBase(long id, String worker) {
|
||||
return new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id)
|
||||
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
|
||||
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.eq(LastlegEventOutbox::getLockedBy, worker);
|
||||
}
|
||||
|
||||
private static String truncate(String value) {
|
||||
String safe = value == null || value.isBlank() ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
return safe.length() > 500 ? safe.substring(0, 500) : safe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
/** Canonical Admin authorities for fourth-phase overseas APIs. */
|
||||
public final class OverseasPermissions {
|
||||
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
|
||||
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
|
||||
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
|
||||
public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list";
|
||||
public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create";
|
||||
public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit";
|
||||
public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list";
|
||||
public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create";
|
||||
public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status";
|
||||
public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list";
|
||||
public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export";
|
||||
public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit";
|
||||
public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust";
|
||||
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
|
||||
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
|
||||
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
|
||||
public static final String TRUCK_LIST = "platform:overseas-business:truck:list";
|
||||
public static final String TRUCK_CREATE = "platform:overseas-business:truck:create";
|
||||
public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit";
|
||||
public static final String TRUCK_STATUS = "platform:overseas-business:truck:status";
|
||||
public static final String LOOSEBOARD_LIST = "platform:overseas-business:looseboard:list";
|
||||
public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create";
|
||||
public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit";
|
||||
public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status";
|
||||
public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list";
|
||||
public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create";
|
||||
public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit";
|
||||
public static final String QUOTE_STATUS = "platform:overseas-master-data:quote:status";
|
||||
public static final String QUOTE_EXPORT = "platform:overseas-master-data:quote:export";
|
||||
public static final String EXCHANGE_RATE_LIST = "platform:overseas-master-data:exchange-rate:list";
|
||||
public static final String EXCHANGE_RATE_CREATE = "platform:overseas-master-data:exchange-rate:create";
|
||||
public static final String EXCHANGE_RATE_EDIT = "platform:overseas-master-data:exchange-rate:edit";
|
||||
public static final String EXCHANGE_RATE_STATUS = "platform:overseas-master-data:exchange-rate:status";
|
||||
public static final String EXCHANGE_RATE_EXPORT = "platform:overseas-master-data:exchange-rate:export";
|
||||
public static final String VEHICLE_LIST = "platform:overseas-master-data:vehicle:list";
|
||||
public static final String VEHICLE_CREATE = "platform:overseas-master-data:vehicle:create";
|
||||
public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit";
|
||||
public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status";
|
||||
public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export";
|
||||
|
||||
private OverseasPermissions() { }
|
||||
}
|
||||
@@ -6,11 +6,6 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
@@ -29,8 +24,6 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class LastlegOrderService {
|
||||
@@ -41,9 +34,6 @@ public class LastlegOrderService {
|
||||
@Resource private LastlegOrderMapper orderMapper;
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
@@ -60,23 +50,9 @@ public class LastlegOrderService {
|
||||
new LambdaQueryWrapper<LastlegOrderStatusHistory>()
|
||||
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
|
||||
List<LastlegOrderDetailResponse.DropshipPallet> pallets = dropshipPallets(customerId, order);
|
||||
return new LastlegOrderDetailResponse(order, cargo, history, pallets);
|
||||
}
|
||||
|
||||
private List<LastlegOrderDetailResponse.DropshipPallet> dropshipPallets(String customerId, LastlegOrder order) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of();
|
||||
var profiles = dropshipProfiles.listOrder(customerId, order.getId());
|
||||
if (profiles.isEmpty()) return List.of();
|
||||
Map<Long, List<DropshipSkuInventory>> skuByProfile = dropshipSkuInventory
|
||||
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId));
|
||||
Map<Long, List<DropshipCarton>> cartonsByProfile = dropshipCartons
|
||||
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId));
|
||||
return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet(
|
||||
profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(),
|
||||
profile.getInventoryPositionId(), profile.getStatus(),
|
||||
skuByProfile.getOrDefault(profile.getId(), List.of()),
|
||||
cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList();
|
||||
return new LastlegOrderDetailResponse(summary(order), cargo, history.stream()
|
||||
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -126,20 +102,16 @@ public class LastlegOrderService {
|
||||
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
|
||||
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
|
||||
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
|
||||
for (LastlegOrder order : orders) {
|
||||
lines.add(String.join(",", List.of(
|
||||
csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()),
|
||||
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
|
||||
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
|
||||
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()),
|
||||
csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
|
||||
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0))),
|
||||
csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
|
||||
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()),
|
||||
csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
|
||||
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
|
||||
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
|
||||
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))));
|
||||
}
|
||||
@@ -184,13 +156,11 @@ public class LastlegOrderService {
|
||||
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
|
||||
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(),
|
||||
o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0)
|
||||
- Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(),
|
||||
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
|
||||
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(),
|
||||
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(),
|
||||
o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt());
|
||||
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
|
||||
o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
|
||||
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static String csv(Object value) {
|
||||
|
||||
+5
@@ -394,12 +394,17 @@ public class WarehouseTransferService {
|
||||
if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt");
|
||||
|
||||
String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId();
|
||||
InventoryBalance sourceBalance = inventoryCommands.lockBalance(customerId, allocation.getInventoryBalanceId());
|
||||
if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) {
|
||||
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
|
||||
}
|
||||
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
|
||||
new InventoryCommandRequests.StockIn(customerId, null,
|
||||
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
|
||||
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
|
||||
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
|
||||
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
|
||||
sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
|
||||
request.destinationPalletId(), request.destinationPalletNo().trim(),
|
||||
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(),
|
||||
trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo,
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public class MerchantUsTruckOrderController {
|
||||
@PatchMapping("/{orderNo}")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
|
||||
return ApiResponse.success(service.update(customer(user), orderNo, request));
|
||||
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
private String customer(AuthenticatedUser user) {
|
||||
|
||||
+35
-6
@@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
||||
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -26,32 +29,59 @@ public class PlatformUsTruckOrderController {
|
||||
@Resource private UsTruckOrderService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')")
|
||||
@Operation(summary = "Page local truck orders", description = "Required authority: " + OverseasPermissions.TRUCK_LIST)
|
||||
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid UsTruckOrderRequests.Query query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(null, query));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_CREATE + "')")
|
||||
@Operation(summary = "Create a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_CREATE)
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @Valid @RequestBody UsTruckOrderRequests.Create request) {
|
||||
requirePermission(user, OverseasPermissions.TRUCK_CREATE);
|
||||
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')")
|
||||
@Operation(summary = "Get a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_LIST)
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_EDIT + "')")
|
||||
@Operation(summary = "Update a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_EDIT)
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody UsTruckOrderRequests.Update request) {
|
||||
requirePermission(user, OverseasPermissions.TRUCK_EDIT);
|
||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||
@Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
|
||||
requirePermission(user);
|
||||
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/receive-pallets")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
|
||||
requirePermission(user);
|
||||
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@@ -62,11 +92,10 @@ public class PlatformUsTruckOrderController {
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePermission(AuthenticatedUser user) {
|
||||
private void requirePermission(AuthenticatedUser user, String permission) {
|
||||
requirePlatform(user);
|
||||
if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
UsTruckOrderService.MANAGE_PERMISSION + " permission is required");
|
||||
if (user.permissions() == null || !user.permissions().contains(permission)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -35,7 +36,7 @@ import java.util.function.Consumer;
|
||||
|
||||
@Service
|
||||
public class UsTruckOrderService {
|
||||
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
|
||||
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
|
||||
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
|
||||
@@ -133,7 +134,8 @@ public class UsTruckOrderService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
|
||||
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||
UsTruckOrderRequests.Update request) {
|
||||
UsTruckOrder order = lock(customerId, orderNo);
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
|
||||
@@ -147,7 +149,9 @@ public class UsTruckOrderService {
|
||||
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
|
||||
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
|
||||
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
|
||||
touch(order); orders.updateById(order); return detail(customerId, orderNo);
|
||||
touch(order); orders.updateById(order);
|
||||
history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now());
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
Reference in New Issue
Block a user