优化
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/exchange-rates")
|
||||
@Tag(name = "Monthly exchange rates / 月度汇率")
|
||||
public class PlatformMonthlyExchangeRateController {
|
||||
@Resource private MonthlyExchangeRateService service;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
@Operation(summary = "Page monthly exchange rates", description = "Required authority: platform:overseas-master-data:exchange-rate:list")
|
||||
public ApiResponse<PageResult<MonthlyExchangeRateDtos.Response>> page(@Valid MonthlyExchangeRateDtos.Query query) {
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
@GetMapping("/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Summary> summary() { return ApiResponse.success(service.summary()); }
|
||||
@GetMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> detail(@PathVariable String month) { return ApiResponse.success(service.detail(month)); }
|
||||
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_CREATE + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody MonthlyExchangeRateDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
|
||||
@PutMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EDIT + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.Update request) {
|
||||
return ApiResponse.success(service.update(month, operator(user), request));
|
||||
}
|
||||
@PatchMapping("/{month}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_STATUS + "')")
|
||||
public ApiResponse<MonthlyExchangeRateDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
|
||||
return ApiResponse.success(service.changeStatus(month, operator(user), request));
|
||||
}
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
|
||||
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class MonthlyExchangeRateDtos {
|
||||
private MonthlyExchangeRateDtos() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthFrom,
|
||||
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthTo,
|
||||
String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
public record Create(@NotBlank @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String month,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
|
||||
@Size(max = 1000) String remark) { }
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
|
||||
@Size(max = 1000) String remark) { }
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion,
|
||||
@Size(max = 500) String remark) { }
|
||||
public record Response(String month, String baseCurrency, String quoteCurrency,
|
||||
BigDecimal usdToCnyAverage, String status, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt, String remark) { }
|
||||
public record Summary(String latestMonth, BigDecimal latestUsdToCnyAverage,
|
||||
String updatedBy, LocalDateTime updatedAt) { }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_monthly_exchange_rate")
|
||||
public class MonthlyExchangeRate {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String rateMonth;
|
||||
private String baseCurrency;
|
||||
private String quoteCurrency;
|
||||
private BigDecimal usdToCnyAverage;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_monthly_exchange_rate_history")
|
||||
public class MonthlyExchangeRateHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long exchangeRateId;
|
||||
private BigDecimal beforeRate;
|
||||
private BigDecimal afterRate;
|
||||
private String beforeStatus;
|
||||
private String afterStatus;
|
||||
private String operatorId;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MonthlyExchangeRateHistoryMapper extends BaseMapper<MonthlyExchangeRateHistory> { }
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface MonthlyExchangeRateMapper extends BaseMapper<MonthlyExchangeRate> {
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1")
|
||||
MonthlyExchangeRate findMonth(@Param("month") String month);
|
||||
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1 FOR UPDATE")
|
||||
MonthlyExchangeRate lockMonth(@Param("month") String month);
|
||||
|
||||
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE status='ACTIVE' ORDER BY rate_month DESC LIMIT 1")
|
||||
MonthlyExchangeRate latestActive();
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class MonthlyExchangeRateService {
|
||||
private static final String ACTIVE = "ACTIVE";
|
||||
private static final String INACTIVE = "INACTIVE";
|
||||
@Resource private MonthlyExchangeRateMapper rates;
|
||||
@Resource private MonthlyExchangeRateHistoryMapper histories;
|
||||
|
||||
public PageResult<MonthlyExchangeRateDtos.Response> page(MonthlyExchangeRateDtos.Query query) {
|
||||
validateRange(query.monthFrom(), query.monthTo());
|
||||
String status = optionalStatus(query.status());
|
||||
Page<MonthlyExchangeRate> page = rates.selectPage(Page.of(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<MonthlyExchangeRate>()
|
||||
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
|
||||
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
|
||||
.eq(status != null, MonthlyExchangeRate::getStatus, status)
|
||||
.orderByDesc(MonthlyExchangeRate::getRateMonth));
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public MonthlyExchangeRateDtos.Response detail(String month) { return response(required(month)); }
|
||||
|
||||
public MonthlyExchangeRateDtos.Summary summary() {
|
||||
MonthlyExchangeRate value = rates.latestActive();
|
||||
return value == null ? new MonthlyExchangeRateDtos.Summary(null, null, null, null)
|
||||
: new MonthlyExchangeRateDtos.Summary(value.getRateMonth(), value.getUsdToCnyAverage(),
|
||||
value.getUpdatedBy(), value.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response create(String operator, MonthlyExchangeRateDtos.Create request) {
|
||||
String month = month(request.month()); LocalDateTime now = LocalDateTime.now();
|
||||
MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setId(IdWorker.getId());
|
||||
value.setRateMonth(month); value.setBaseCurrency("USD"); value.setQuoteCurrency("CNY");
|
||||
value.setUsdToCnyAverage(request.usdToCnyAverage()); value.setStatus(ACTIVE);
|
||||
value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
|
||||
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
try { rates.insert(value); }
|
||||
catch (DuplicateKeyException exception) { throw conflict("该月份汇率已存在"); }
|
||||
history(value.getId(), null, value.getUsdToCnyAverage(), null, ACTIVE, operator, request.remark());
|
||||
return detail(month);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response update(String month, String operator,
|
||||
MonthlyExchangeRateDtos.Update request) {
|
||||
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
|
||||
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
|
||||
.eq(MonthlyExchangeRate::getId, current.getId())
|
||||
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
|
||||
.set(MonthlyExchangeRate::getUsdToCnyAverage, request.usdToCnyAverage())
|
||||
.set(MonthlyExchangeRate::getRemark, trim(request.remark()))
|
||||
.set(MonthlyExchangeRate::getUpdatedBy, operator).set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
history(current.getId(), current.getUsdToCnyAverage(), request.usdToCnyAverage(),
|
||||
current.getStatus(), current.getStatus(), operator, request.remark());
|
||||
return detail(current.getRateMonth());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MonthlyExchangeRateDtos.Response changeStatus(String month, String operator,
|
||||
MonthlyExchangeRateDtos.ChangeStatus request) {
|
||||
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
|
||||
String status = status(request.status());
|
||||
if (status.equals(current.getStatus())) return response(current);
|
||||
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
|
||||
.eq(MonthlyExchangeRate::getId, current.getId())
|
||||
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
|
||||
.set(MonthlyExchangeRate::getStatus, status).set(MonthlyExchangeRate::getUpdatedBy, operator)
|
||||
.set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
history(current.getId(), current.getUsdToCnyAverage(), current.getUsdToCnyAverage(),
|
||||
current.getStatus(), status, operator, request.remark());
|
||||
return detail(current.getRateMonth());
|
||||
}
|
||||
|
||||
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
|
||||
validateRange(query.monthFrom(), query.monthTo());
|
||||
String status = optionalStatus(query.status());
|
||||
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
|
||||
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
|
||||
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
|
||||
.eq(status != null, MonthlyExchangeRate::getStatus, status)
|
||||
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
|
||||
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
|
||||
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
|
||||
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
|
||||
String beforeStatus, String afterStatus, String operator, String remark) {
|
||||
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
|
||||
value.setExchangeRateId(id); value.setBeforeRate(beforeRate); value.setAfterRate(afterRate);
|
||||
value.setBeforeStatus(beforeStatus); value.setAfterStatus(afterStatus); value.setOperatorId(operator);
|
||||
value.setRemark(trim(remark)); value.setCreatedAt(LocalDateTime.now()); histories.insert(value);
|
||||
}
|
||||
private MonthlyExchangeRate required(String month) {
|
||||
MonthlyExchangeRate value = rates.findMonth(month(month));
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
|
||||
}
|
||||
private MonthlyExchangeRate locked(String month) {
|
||||
MonthlyExchangeRate value = rates.lockMonth(month(month));
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
|
||||
}
|
||||
private MonthlyExchangeRateDtos.Response response(MonthlyExchangeRate value) {
|
||||
return new MonthlyExchangeRateDtos.Response(value.getRateMonth(), value.getBaseCurrency(),
|
||||
value.getQuoteCurrency(), value.getUsdToCnyAverage(), value.getStatus(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
|
||||
}
|
||||
private void requireVersion(MonthlyExchangeRate value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("汇率已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private void validateRange(String from, String to) {
|
||||
if (StringUtils.hasText(from) && StringUtils.hasText(to) && from.compareTo(to) > 0) throw badRequest("monthFrom不能晚于monthTo");
|
||||
}
|
||||
private String month(String value) {
|
||||
if (value == null || !value.matches("\\d{4}-(0[1-9]|1[0-2])")) throw badRequest("月份格式必须为yyyy-MM"); return value;
|
||||
}
|
||||
private String optionalStatus(String value) { return StringUtils.hasText(value) ? status(value) : null; }
|
||||
private String status(String value) {
|
||||
String result = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!ACTIVE.equals(result) && !INACTIVE.equals(result)) throw badRequest("汇率状态只支持ACTIVE/INACTIVE"); return result;
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "domesticPartnerWarehouseClient",
|
||||
url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface DomesticPartnerWarehouseClient {
|
||||
@GetMapping("/internal/warehouses/overseas-partners")
|
||||
ApiResponse<List<Option>> activePartners(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken);
|
||||
|
||||
record Option(Long id, String warehouseCode, String warehouseName, String countryCode) { }
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/overseas-quotes")
|
||||
@Tag(name = "Overseas warehouse quotes / 海外仓报价")
|
||||
public class PlatformOverseasQuoteController {
|
||||
@Resource private OverseasQuoteService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "Page overseas warehouse quotes",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list")
|
||||
public ApiResponse<PageResult<OverseasQuoteResponses.Summary>> page(
|
||||
@Valid OverseasQuoteRequests.Query query) {
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "Get overseas warehouse quote detail",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> detail(@PathVariable long id) {
|
||||
return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/warehouse-options")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||
@Operation(summary = "List cached active overseas partner warehouse options",
|
||||
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs")
|
||||
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
|
||||
return ApiResponse.success(service.warehouseOptions());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_CREATE + "')")
|
||||
@Operation(summary = "Create an inactive overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:create")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> create(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody OverseasQuoteRequests.Create request) {
|
||||
return ApiResponse.success(service.create(operator(user), request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EDIT + "')")
|
||||
@Operation(summary = "Update an inactive overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:edit")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody OverseasQuoteRequests.Update request) {
|
||||
return ApiResponse.success(service.update(id, operator(user), request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_STATUS + "')")
|
||||
@Operation(summary = "Enable or disable an overseas warehouse quote",
|
||||
description = "Required authority: platform:overseas-master-data:quote:status")
|
||||
public ApiResponse<OverseasQuoteResponses.Detail> status(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody OverseasQuoteRequests.ChangeStatus request) {
|
||||
return ApiResponse.success(service.changeStatus(id, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered overseas warehouse quotes",
|
||||
description = "Required authority: platform:overseas-master-data:quote:export")
|
||||
public ResponseEntity<byte[]> export(@Valid OverseasQuoteRequests.Query query) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN";
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class OverseasQuoteRequests {
|
||||
private OverseasQuoteRequests() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
String keyword, String warehouseId, String providerName,
|
||||
String currency, String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
|
||||
public record Fee(@NotBlank @Size(max = 128) String item,
|
||||
@NotBlank @Size(max = 64) String billingUnit,
|
||||
@NotNull @DecimalMin("0") BigDecimal unitPrice,
|
||||
@DecimalMin("0") BigDecimal minimumCharge,
|
||||
@Size(max = 500) String remark) { }
|
||||
|
||||
public record TransitPrice(@NotBlank @Size(max = 64) String fbaWarehouseCode,
|
||||
@NotBlank @Size(max = 64) String deliveryMethod,
|
||||
@DecimalMin("0") BigDecimal estimatedMiles,
|
||||
@NotNull @DecimalMin("0") BigDecimal price,
|
||||
@DecimalMin("0") BigDecimal minimumCharge,
|
||||
@Size(max = 500) String remark) { }
|
||||
|
||||
public record Create(@NotBlank @Size(max = 128) String name,
|
||||
@NotBlank String warehouseId,
|
||||
@NotBlank @Size(max = 128) String providerName,
|
||||
@NotBlank @Size(max = 3) String currency,
|
||||
@NotNull LocalDate effectiveStart,
|
||||
@NotNull LocalDate effectiveEnd,
|
||||
@NotBlank @Size(max = 64) String versionCode,
|
||||
@NotEmpty List<@Valid Fee> warehouseFees,
|
||||
List<@Valid TransitPrice> transitPrices,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(max = 128) String name,
|
||||
@NotBlank @Size(max = 128) String providerName,
|
||||
@NotBlank @Size(max = 3) String currency,
|
||||
@NotNull LocalDate effectiveStart,
|
||||
@NotNull LocalDate effectiveEnd,
|
||||
@NotEmpty List<@Valid Fee> warehouseFees,
|
||||
List<@Valid TransitPrice> transitPrices,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class OverseasQuoteResponses {
|
||||
private OverseasQuoteResponses() { }
|
||||
|
||||
public record PartnerWarehouseOption(String warehouseId, String warehouseCode,
|
||||
String warehouseName, String countryCode) { }
|
||||
|
||||
public record Summary(String id, String name, String warehouseId, String warehouseCode,
|
||||
String warehouseName, String countryCode, String providerName,
|
||||
String currency, LocalDate effectiveStart, LocalDate effectiveEnd,
|
||||
String versionCode, String status, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt, String remark) { }
|
||||
|
||||
public record Fee(String item, String billingUnit, BigDecimal unitPrice,
|
||||
BigDecimal minimumCharge, String remark) { }
|
||||
|
||||
public record TransitPrice(String fbaWarehouseCode, String deliveryMethod,
|
||||
BigDecimal estimatedMiles, BigDecimal price,
|
||||
BigDecimal minimumCharge, String remark) { }
|
||||
|
||||
public record Detail(Summary summary, List<Fee> warehouseFees,
|
||||
List<TransitPrice> transitPrices,
|
||||
String createdBy, LocalDateTime createdAt) { }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote")
|
||||
public class OverseasQuote {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String name;
|
||||
private Long warehouseId;
|
||||
private String warehouseCode;
|
||||
private String warehouseName;
|
||||
private String countryCode;
|
||||
private String providerName;
|
||||
private String currency;
|
||||
private LocalDate effectiveStart;
|
||||
private LocalDate effectiveEnd;
|
||||
private String versionCode;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote_fee")
|
||||
public class OverseasQuoteFee {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long quoteId;
|
||||
private Integer sequenceNo;
|
||||
private String item;
|
||||
private String billingUnit;
|
||||
private BigDecimal unitPrice;
|
||||
private BigDecimal minimumCharge;
|
||||
private String remark;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_quote_transit_price")
|
||||
public class OverseasQuoteTransitPrice {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long quoteId;
|
||||
private Integer sequenceNo;
|
||||
private String fbaWarehouseCode;
|
||||
private String deliveryMethod;
|
||||
private BigDecimal estimatedMiles;
|
||||
private BigDecimal price;
|
||||
private BigDecimal minimumCharge;
|
||||
private String remark;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteFeeMapper extends BaseMapper<OverseasQuoteFee> { }
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteMapper extends BaseMapper<OverseasQuote> {
|
||||
@Select("SELECT * FROM ll_overseas_quote WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
OverseasQuote lockById(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_overseas_quote
|
||||
WHERE warehouse_id=#{warehouseId} AND provider_name=#{providerName} AND currency=#{currency}
|
||||
AND status='ACTIVE' AND id<>#{excludedId}
|
||||
AND effective_start<=#{effectiveEnd} AND effective_end>=#{effectiveStart}
|
||||
""")
|
||||
int countActiveOverlap(@Param("warehouseId") long warehouseId,
|
||||
@Param("providerName") String providerName,
|
||||
@Param("currency") String currency,
|
||||
@Param("effectiveStart") LocalDate effectiveStart,
|
||||
@Param("effectiveEnd") LocalDate effectiveEnd,
|
||||
@Param("excludedId") long excludedId);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasQuoteTransitPriceMapper extends BaseMapper<OverseasQuoteTransitPrice> { }
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
|
||||
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteFeeMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteMapper;
|
||||
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteTransitPriceMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class OverseasQuoteService {
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
public static final String INACTIVE = "INACTIVE";
|
||||
@Resource private OverseasQuoteMapper quotes;
|
||||
@Resource private OverseasQuoteFeeMapper fees;
|
||||
@Resource private OverseasQuoteTransitPriceMapper transitPrices;
|
||||
@Resource private PartnerWarehouseDirectory warehouses;
|
||||
|
||||
public PageResult<OverseasQuoteResponses.Summary> page(OverseasQuoteRequests.Query query) {
|
||||
Long warehouseId = parseOptionalId(query.warehouseId());
|
||||
String status = normalizeOptionalStatus(query.status());
|
||||
var wrapper = new LambdaQueryWrapper<OverseasQuote>()
|
||||
.and(StringUtils.hasText(query.keyword()), value -> value
|
||||
.like(OverseasQuote::getName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getWarehouseName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getProviderName, query.keyword().trim())
|
||||
.or().like(OverseasQuote::getVersionCode, query.keyword().trim()))
|
||||
.eq(warehouseId != null, OverseasQuote::getWarehouseId, warehouseId)
|
||||
.like(StringUtils.hasText(query.providerName()), OverseasQuote::getProviderName,
|
||||
StringUtils.hasText(query.providerName()) ? query.providerName().trim() : null)
|
||||
.eq(StringUtils.hasText(query.currency()), OverseasQuote::getCurrency,
|
||||
StringUtils.hasText(query.currency()) ? query.currency().trim().toUpperCase(Locale.ROOT) : null)
|
||||
.eq(status != null, OverseasQuote::getStatus, status)
|
||||
.orderByDesc(OverseasQuote::getUpdatedAt).orderByDesc(OverseasQuote::getId);
|
||||
Page<OverseasQuote> page = quotes.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public OverseasQuoteResponses.Detail detail(long id) {
|
||||
OverseasQuote quote = required(id);
|
||||
List<OverseasQuoteResponses.Fee> feeRows = fees.selectList(new LambdaQueryWrapper<OverseasQuoteFee>()
|
||||
.eq(OverseasQuoteFee::getQuoteId, id).orderByAsc(OverseasQuoteFee::getSequenceNo))
|
||||
.stream().map(value -> new OverseasQuoteResponses.Fee(value.getItem(), value.getBillingUnit(),
|
||||
value.getUnitPrice(), value.getMinimumCharge(), value.getRemark())).toList();
|
||||
List<OverseasQuoteResponses.TransitPrice> transitRows = transitPrices.selectList(
|
||||
new LambdaQueryWrapper<OverseasQuoteTransitPrice>().eq(OverseasQuoteTransitPrice::getQuoteId, id)
|
||||
.orderByAsc(OverseasQuoteTransitPrice::getSequenceNo))
|
||||
.stream().map(value -> new OverseasQuoteResponses.TransitPrice(value.getFbaWarehouseCode(),
|
||||
value.getDeliveryMethod(), value.getEstimatedMiles(), value.getPrice(),
|
||||
value.getMinimumCharge(), value.getRemark())).toList();
|
||||
return new OverseasQuoteResponses.Detail(summary(quote), feeRows, transitRows,
|
||||
quote.getCreatedBy(), quote.getCreatedAt());
|
||||
}
|
||||
|
||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
|
||||
return warehouses.options();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail create(String operator, OverseasQuoteRequests.Create request) {
|
||||
validateDates(request.effectiveStart(), request.effectiveEnd());
|
||||
long warehouseId = parseId(request.warehouseId());
|
||||
var warehouse = warehouses.required(warehouseId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OverseasQuote quote = new OverseasQuote();
|
||||
quote.setId(IdWorker.getId()); quote.setName(request.name().trim()); quote.setWarehouseId(warehouseId);
|
||||
quote.setWarehouseCode(warehouse.warehouseCode()); quote.setWarehouseName(warehouse.warehouseName());
|
||||
quote.setCountryCode(warehouse.countryCode()); quote.setProviderName(request.providerName().trim());
|
||||
quote.setCurrency(currency(request.currency())); quote.setEffectiveStart(request.effectiveStart());
|
||||
quote.setEffectiveEnd(request.effectiveEnd()); quote.setVersionCode(request.versionCode().trim());
|
||||
quote.setStatus(INACTIVE); quote.setRemark(trim(request.remark())); quote.setRecordVersion(0L);
|
||||
quote.setCreatedBy(operator); quote.setUpdatedBy(operator); quote.setCreatedAt(now); quote.setUpdatedAt(now);
|
||||
try { quotes.insert(quote); }
|
||||
catch (DuplicateKeyException exception) { throw conflict("同一合作仓、报价方和版本号已存在"); }
|
||||
replaceChildren(quote.getId(), request.warehouseFees(), request.transitPrices());
|
||||
return detail(quote.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail update(long id, String operator, OverseasQuoteRequests.Update request) {
|
||||
validateDates(request.effectiveStart(), request.effectiveEnd());
|
||||
OverseasQuote current = locked(id);
|
||||
requireVersion(current, request.recordVersion());
|
||||
if (ACTIVE.equals(current.getStatus())) throw conflict("启用中的报价不能直接编辑,请先停用");
|
||||
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
|
||||
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
|
||||
.set(OverseasQuote::getName, request.name().trim())
|
||||
.set(OverseasQuote::getProviderName, request.providerName().trim())
|
||||
.set(OverseasQuote::getCurrency, currency(request.currency()))
|
||||
.set(OverseasQuote::getEffectiveStart, request.effectiveStart())
|
||||
.set(OverseasQuote::getEffectiveEnd, request.effectiveEnd())
|
||||
.set(OverseasQuote::getRemark, trim(request.remark()))
|
||||
.set(OverseasQuote::getUpdatedBy, operator).set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
replaceChildren(id, request.warehouseFees(), request.transitPrices());
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasQuoteResponses.Detail changeStatus(long id, String operator,
|
||||
OverseasQuoteRequests.ChangeStatus request) {
|
||||
OverseasQuote current = locked(id);
|
||||
requireVersion(current, request.recordVersion());
|
||||
String status = normalizeStatus(request.status());
|
||||
if (status.equals(current.getStatus())) return detail(id);
|
||||
if (ACTIVE.equals(status) && quotes.countActiveOverlap(current.getWarehouseId(), current.getProviderName(),
|
||||
current.getCurrency(), current.getEffectiveStart(), current.getEffectiveEnd(), id) > 0) {
|
||||
throw conflict("同一合作仓、报价方和币种存在生效区间重叠的启用报价");
|
||||
}
|
||||
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
|
||||
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
|
||||
.set(OverseasQuote::getStatus, status).set(OverseasQuote::getUpdatedBy, operator)
|
||||
.set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(OverseasQuoteRequests.Query query) {
|
||||
OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(),
|
||||
query.warehouseId(), query.providerName(), query.currency(), query.status());
|
||||
StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n");
|
||||
for (var row : page(exportQuery).records()) {
|
||||
csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',')
|
||||
.append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',')
|
||||
.append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',')
|
||||
.append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',')
|
||||
.append(cell(row.versionCode())).append(',').append(row.status()).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
}
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void replaceChildren(long quoteId, List<OverseasQuoteRequests.Fee> feeRows,
|
||||
List<OverseasQuoteRequests.TransitPrice> transitRows) {
|
||||
fees.delete(new LambdaQueryWrapper<OverseasQuoteFee>().eq(OverseasQuoteFee::getQuoteId, quoteId));
|
||||
transitPrices.delete(new LambdaQueryWrapper<OverseasQuoteTransitPrice>()
|
||||
.eq(OverseasQuoteTransitPrice::getQuoteId, quoteId));
|
||||
int sequence = 1;
|
||||
for (var row : feeRows) {
|
||||
OverseasQuoteFee value = new OverseasQuoteFee(); value.setId(IdWorker.getId()); value.setQuoteId(quoteId);
|
||||
value.setSequenceNo(sequence++); value.setItem(row.item().trim());
|
||||
value.setBillingUnit(row.billingUnit().trim()); value.setUnitPrice(row.unitPrice());
|
||||
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
|
||||
fees.insert(value);
|
||||
}
|
||||
sequence = 1;
|
||||
for (var row : transitRows == null ? List.<OverseasQuoteRequests.TransitPrice>of() : transitRows) {
|
||||
OverseasQuoteTransitPrice value = new OverseasQuoteTransitPrice(); value.setId(IdWorker.getId());
|
||||
value.setQuoteId(quoteId); value.setSequenceNo(sequence++);
|
||||
value.setFbaWarehouseCode(row.fbaWarehouseCode().trim().toUpperCase(Locale.ROOT));
|
||||
value.setDeliveryMethod(row.deliveryMethod().trim().toUpperCase(Locale.ROOT));
|
||||
value.setEstimatedMiles(zeroIfNull(row.estimatedMiles())); value.setPrice(row.price());
|
||||
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
|
||||
transitPrices.insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
private OverseasQuoteResponses.Summary summary(OverseasQuote value) {
|
||||
return new OverseasQuoteResponses.Summary(value.getId().toString(), value.getName(),
|
||||
value.getWarehouseId().toString(), value.getWarehouseCode(), value.getWarehouseName(),
|
||||
value.getCountryCode(), value.getProviderName(), value.getCurrency(), value.getEffectiveStart(),
|
||||
value.getEffectiveEnd(), value.getVersionCode(), value.getStatus(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
|
||||
}
|
||||
|
||||
private OverseasQuote required(long id) {
|
||||
OverseasQuote value = quotes.selectById(id);
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
|
||||
return value;
|
||||
}
|
||||
private OverseasQuote locked(long id) {
|
||||
OverseasQuote value = quotes.lockById(id);
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
|
||||
return value;
|
||||
}
|
||||
private void requireVersion(OverseasQuote value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("报价已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private void validateDates(java.time.LocalDate start, java.time.LocalDate end) {
|
||||
if (end.isBefore(start)) throw badRequest("报价结束日期不能早于开始日期");
|
||||
}
|
||||
private Long parseOptionalId(String value) { return StringUtils.hasText(value) ? parseId(value) : null; }
|
||||
private long parseId(String value) {
|
||||
try { long id = Long.parseLong(value.trim()); if (id <= 0) throw new NumberFormatException(); return id; }
|
||||
catch (NumberFormatException exception) { throw badRequest("warehouseId 无效"); }
|
||||
}
|
||||
private String normalizeOptionalStatus(String value) { return StringUtils.hasText(value) ? normalizeStatus(value) : null; }
|
||||
private String normalizeStatus(String value) {
|
||||
String status = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!ACTIVE.equals(status) && !INACTIVE.equals(status)) throw badRequest("报价状态只支持 ACTIVE/INACTIVE");
|
||||
return status;
|
||||
}
|
||||
private String currency(String value) {
|
||||
String currency = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (currency.length() != 3) throw badRequest("币种必须为三位代码");
|
||||
return currency;
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.masterdata.quote.service;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Single-key immutable snapshot cache for active domestic overseas-partner warehouses. */
|
||||
@Component
|
||||
public class PartnerWarehouseDirectory {
|
||||
@Resource private DomesticPartnerWarehouseClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl;
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
|
||||
Snapshot current = snapshot;
|
||||
Instant now = clock.instant();
|
||||
if (current != null && now.isBefore(current.expiresAt())) return current.options();
|
||||
synchronized (this) {
|
||||
current = snapshot;
|
||||
now = clock.instant();
|
||||
if (current != null && now.isBefore(current.expiresAt())) return current.options();
|
||||
var response = client.activePartners(internalToken);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"国内海外合作仓候选暂不可用");
|
||||
}
|
||||
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
|
||||
.filter(value -> value != null && value.id() != null)
|
||||
.map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(),
|
||||
value.warehouseCode(), value.warehouseName(), value.countryCode()))
|
||||
.toList();
|
||||
snapshot = new Snapshot(options, now.plus(ttl));
|
||||
return snapshot.options();
|
||||
}
|
||||
}
|
||||
|
||||
public OverseasQuoteResponses.PartnerWarehouseOption required(long warehouseId) {
|
||||
return options().stream().filter(value -> Long.toString(warehouseId).equals(value.warehouseId()))
|
||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"所选海外合作仓不存在或已停用"));
|
||||
}
|
||||
|
||||
private record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
|
||||
private Snapshot { options = List.copyOf(options); }
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
|
||||
@Tag(name = "Vehicle master data / 车辆主数据")
|
||||
public class PlatformVehicleController {
|
||||
@Resource private VehicleService service;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
|
||||
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
|
||||
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
|
||||
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
|
||||
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
|
||||
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
|
||||
|
||||
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
|
||||
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
|
||||
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
|
||||
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
|
||||
|
||||
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
|
||||
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
|
||||
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Digits;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class VehicleDtos {
|
||||
private VehicleDtos() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Size(max = 128) String keyword, String ownership, String region, String status) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
|
||||
public record Create(@NotBlank @Size(max = 64) String plateNumber,
|
||||
@NotBlank @Size(max = 128) String model,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
|
||||
@NotNull @Min(0) Long mileageKm,
|
||||
@Size(max = 128) String driverOrDispatchCompany,
|
||||
@Size(max = 64) String phone,
|
||||
@NotBlank String region, @NotBlank String ownership,
|
||||
@NotBlank String status, @Size(max = 1000) String remark) { }
|
||||
|
||||
public record Update(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(max = 64) String plateNumber,
|
||||
@NotBlank @Size(max = 128) String model,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
|
||||
@NotNull @Min(0) Long mileageKm,
|
||||
@Size(max = 128) String driverOrDispatchCompany,
|
||||
@Size(max = 64) String phone,
|
||||
@NotBlank String region, @NotBlank String ownership,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
|
||||
|
||||
public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
|
||||
long mileageKm, String driverOrDispatchCompany, String phone, String region,
|
||||
String ownership, String status, String remark, long recordVersion,
|
||||
String updatedBy, LocalDateTime updatedAt) { }
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_vehicle")
|
||||
public class Vehicle {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String plateNumber;
|
||||
private String model;
|
||||
private BigDecimal fuelConsumptionPer100Km;
|
||||
private Long mileageKm;
|
||||
private String driverOrDispatchCompany;
|
||||
private String phone;
|
||||
private String region;
|
||||
private String ownership;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface VehicleMapper extends BaseMapper<Vehicle> {
|
||||
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
Vehicle lockById(@Param("id") long id);
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.lastleg.masterdata.vehicle.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class VehicleService {
|
||||
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
|
||||
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
|
||||
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
|
||||
@Resource private VehicleMapper vehicles;
|
||||
|
||||
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
|
||||
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
|
||||
page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public VehicleDtos.Response detail(long id) { return response(required(id)); }
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
|
||||
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
|
||||
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
|
||||
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
|
||||
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
|
||||
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
|
||||
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
|
||||
return detail(value.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
|
||||
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
|
||||
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
|
||||
int updated;
|
||||
try {
|
||||
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
|
||||
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
|
||||
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
|
||||
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
|
||||
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
|
||||
.set(Vehicle::getMileageKm, request.mileageKm())
|
||||
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
|
||||
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
|
||||
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
|
||||
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("record_version = record_version + 1"));
|
||||
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
|
||||
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
|
||||
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
|
||||
String status = status(request.status());
|
||||
if (status.equals(current.getStatus())) return response(current);
|
||||
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
|
||||
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
|
||||
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
|
||||
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
|
||||
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(VehicleDtos.Query query) {
|
||||
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
|
||||
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
|
||||
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
|
||||
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
|
||||
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
|
||||
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
|
||||
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
|
||||
return new LambdaQueryWrapper<Vehicle>()
|
||||
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
|
||||
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
|
||||
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
|
||||
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
|
||||
}
|
||||
private Vehicle required(long id) {
|
||||
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
|
||||
}
|
||||
private Vehicle locked(long id) {
|
||||
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
|
||||
}
|
||||
private VehicleDtos.Response response(Vehicle value) {
|
||||
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
|
||||
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
|
||||
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
|
||||
value.getUpdatedBy(), value.getUpdatedAt());
|
||||
}
|
||||
private void requireVersion(Vehicle value, long expected) {
|
||||
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
|
||||
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
|
||||
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
|
||||
private static String optional(String value, Set<String> allowed, String label) {
|
||||
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
|
||||
}
|
||||
private static String required(String value, Set<String> allowed, String label) {
|
||||
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
|
||||
String normalized = value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
|
||||
}
|
||||
private static String requiredText(String value, String label) {
|
||||
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
Reference in New Issue
Block a user