This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
@@ -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()); }
}
@@ -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) { }
}
@@ -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;
}
@@ -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;
}
@@ -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> { }
@@ -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();
}
@@ -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); }
}
@@ -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) { }
}
@@ -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());
}
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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> { }
@@ -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&lt;&gt;#{excludedId}
AND effective_start&lt;=#{effectiveEnd} AND effective_end&gt;=#{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);
}
@@ -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> { }
@@ -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); }
}
@@ -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); }
}
}
@@ -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());
}
}
@@ -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) { }
}
@@ -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;
}
@@ -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);
}
@@ -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); }
}