暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.procurement;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.procurement.mapper")
public class ProcurementMapperConfiguration {
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@PostMapping
public ApiResponse<OverseasProcurementResponses.Detail> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
private String customer(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, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,96 @@
package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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.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/overseas-procurements")
public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service;
@GetMapping
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid OverseasProcurementRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(trim(customerId), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
}
@PostMapping("/{orderNo}/review")
public ApiResponse<OverseasProcurementResponses.Detail> review(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
requirePermission(user, OverseasProcurementService.REVIEW_PERMISSION);
return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PatchMapping("/{orderNo}")
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));
}
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) {
String value = trim(customerId);
if (value == null || "0".equals(value)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
}
return value;
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.procurement.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.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class OverseasProcurementRequests {
private OverseasProcurementRequests() {}
public record Create(
@Size(max = 255) String customerName,
@NotBlank @Size(max = 128) String purchaseCategory,
@NotBlank @Size(max = 255) String productName,
@Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl,
@NotNull @Positive Long purchaseQuantity,
@DecimalMin(value = "0.00") BigDecimal budgetUnitPrice,
@Size(min = 3, max = 3) String currency,
@Size(max = 1000) String receivingAddress,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String customerKeyword, String purchaseCategory,
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Update(
@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,
@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) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.procurement.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class OverseasProcurementResponses {
private OverseasProcurementResponses() {}
public record Summary(
Long id, String orderNo, String customerId, String customerName,
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName,
long inboundQuantity, long outboundQuantity, 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 History(
String fromStatus, String toStatus, String operatorId, String remark,
LocalDateTime createdAt) {}
public record Detail(
Summary summary, String productUrl, String receivingAddress,
String contactName, String contactPhone,
String domesticCarrier, String domesticTrackingNo,
String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<Outbound> outbounds, List<History> histories) {}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.procurement.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_overseas_procurement_order")
public class OverseasProcurementOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String customerName;
private String orderNo;
private String purchaseCategory;
private String productName;
private String productSpecification;
private String productUrl;
private Long purchaseQuantity;
private BigDecimal budgetUnitPrice;
private String currency;
private String receivingAddress;
private String contactName;
private String contactPhone;
private String purchaseWarehouseId;
private String purchaseWarehouseName;
private Long inboundQuantity;
private Long outboundQuantity;
private String domesticCarrier;
private String domesticTrackingNo;
private String overseasCarrier;
private String overseasTrackingNo;
private LocalDateTime estimatedArrivalAt;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.procurement.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.LocalDateTime;
@Data
@TableName("ll_overseas_procurement_outbound")
public class OverseasProcurementOutbound {
@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 remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.procurement.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.LocalDateTime;
@Data
@TableName("ll_overseas_procurement_status_history")
public class OverseasProcurementStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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;
@Mapper
public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcurementOrder> {
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
OverseasProcurementOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OverseasProcurementOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@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);
}
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
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 OverseasProcurementStatusHistoryMapper extends BaseMapper<OverseasProcurementStatusHistory> {
@Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
List<OverseasProcurementStatusHistory> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -0,0 +1,293 @@
package com.baiyue.lastleg.procurement.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.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.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.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 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 ObjectMapper objectMapper;
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
OverseasProcurementOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
OverseasProcurementOrder order = new OverseasProcurementOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setPurchaseCategory(request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
order.setProductUrl(trim(request.productUrl()));
order.setPurchaseQuantity(request.purchaseQuantity());
order.setBudgetUnitPrice(request.budgetUnitPrice());
order.setCurrency(defaultCurrency(request.currency()));
order.setReceivingAddress(trim(request.receivingAddress()));
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);
order.setRequestHash(requestHash);
order.setCreatedBy(operatorId);
order.setVersion(0L);
order.setCreatedAt(now);
order.setUpdatedAt(now);
try {
orders.insert(order);
} catch (DuplicateKeyException exception) {
OverseasProcurementOrder concurrent = orders.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public PageResult<OverseasProcurementResponses.Summary> page(
String customerId, OverseasProcurementRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<OverseasProcurementOrder> wrapper = new LambdaQueryWrapper<OverseasProcurementOrder>()
.eq(StringUtils.hasText(customerId), OverseasProcurementOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), OverseasProcurementOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.customerKeyword()), value -> value
.like(OverseasProcurementOrder::getCustomerId, query.customerKeyword())
.or().like(OverseasProcurementOrder::getCustomerName, query.customerKeyword()))
.like(StringUtils.hasText(query.purchaseCategory()), OverseasProcurementOrder::getPurchaseCategory,
query.purchaseCategory())
.like(StringUtils.hasText(query.productName()), OverseasProcurementOrder::getProductName,
query.productName())
.eq(StringUtils.hasText(query.status()), OverseasProcurementOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.eq(StringUtils.hasText(query.purchaseWarehouseId()), OverseasProcurementOrder::getPurchaseWarehouseId,
query.purchaseWarehouseId())
.ge(query.createdFrom() != null, OverseasProcurementOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, OverseasProcurementOrder::getCreatedAt, query.createdTo())
.orderByDesc(OverseasProcurementOrder::getCreatedAt)
.orderByDesc(OverseasProcurementOrder::getId);
Page<OverseasProcurementOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), query.page(), query.size(),
result.getTotal(), result.getPages());
}
public OverseasProcurementResponses.Detail detail(String customerId, String orderNo) {
OverseasProcurementOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound("Overseas procurement order not found");
return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(),
order.getReceivingAddress(), order.getContactName(), order.getContactPhone(),
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());
}
@Transactional
public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.Review request) {
String next = switch (request.decision().trim().toUpperCase()) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
OverseasProcurementOrder order = lock(customerId, orderNo);
if (next.equals(order.getStatus())) return detail(customerId, orderNo);
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
order.setReviewedBy(operatorId);
order.setReviewRemark(trim(request.remark()));
order.setReviewedAt(now);
touch(order, now);
orders.updateById(order);
history(order, PENDING_REVIEW, next, operatorId, request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public OverseasProcurementResponses.Detail update(String customerId, String orderNo,
OverseasProcurementRequests.Update request) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
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());
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");
}
return detail(customerId, orderNo);
}
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) {
throw conflict("Idempotency key was reused with different procurement data");
}
return detail(customerId, order.getOrderNo());
}
private OverseasProcurementOrder lock(String customerId, String orderNo) {
OverseasProcurementOrder order = orders.lockNo(customerId, orderNo);
if (order == null) throw notFound("Overseas procurement order not found");
return order;
}
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());
}
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) {
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
history.setCustomerId(order.getCustomerId());
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
}
private void validateQuery(OverseasProcurementRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) {
throw badRequest("Unsupported procurement status");
}
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) throw badRequest("createdFrom must not be after createdTo");
}
private String hash(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash procurement request", exception);
}
}
private static void touch(OverseasProcurementOrder 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 defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); }
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);
}
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);
}
}