feat: add denomination plan APIs

master
chenyuan 4 weeks ago
parent 79b1110c1f
commit bda520215c

@ -0,0 +1,19 @@
package com.yau.digitalrmb.issuance.application.query;
import java.math.BigDecimal;
public final class DenominationPlanItemView {
private final BigDecimal denomination;
private final int quantity;
private final BigDecimal subtotal;
public DenominationPlanItemView(BigDecimal denomination, int quantity, BigDecimal subtotal) {
this.denomination = denomination;
this.quantity = quantity;
this.subtotal = subtotal;
}
public BigDecimal getDenomination() { return denomination; }
public int getQuantity() { return quantity; }
public BigDecimal getSubtotal() { return subtotal; }
}

@ -0,0 +1,25 @@
package com.yau.digitalrmb.issuance.application.query;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
public final class DenominationPlanView {
private final List<DenominationPlanItemView> items;
private final int totalQuantity;
private final BigDecimal totalAmount;
private final boolean valid;
public DenominationPlanView(List<DenominationPlanItemView> items, int totalQuantity, BigDecimal totalAmount,
boolean valid) {
this.items = Collections.unmodifiableList(items);
this.totalQuantity = totalQuantity;
this.totalAmount = totalAmount;
this.valid = valid;
}
public List<DenominationPlanItemView> getItems() { return items; }
public int getTotalQuantity() { return totalQuantity; }
public BigDecimal getTotalAmount() { return totalAmount; }
public boolean isValid() { return valid; }
}

@ -189,10 +189,10 @@ public class CommercialBankIssuanceApplicationService {
}
}
private List<Map<String, Integer>> denominationPayload(List<DenominationItem> denominations) {
List<Map<String, Integer>> payload = new ArrayList<Map<String, Integer>>();
private List<Map<String, Object>> denominationPayload(List<DenominationItem> denominations) {
List<Map<String, Object>> payload = new ArrayList<Map<String, Object>>();
for (DenominationItem denomination : denominations) {
Map<String, Integer> item = new LinkedHashMap<String, Integer>();
Map<String, Object> item = new LinkedHashMap<String, Object>();
item.put("denomination", denomination.getDenomination());
item.put("quantity", denomination.getQuantity());
payload.add(item);

@ -0,0 +1,103 @@
package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.issuance.application.query.DenominationPlanItemView;
import com.yau.digitalrmb.issuance.application.query.DenominationPlanView;
import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class DenominationPlanService {
private static final List<BigDecimal> STANDARD_DENOMINATIONS = Arrays.asList(
new BigDecimal("100.00"), new BigDecimal("50.00"), new BigDecimal("20.00"),
new BigDecimal("10.00"), new BigDecimal("5.00"), new BigDecimal("1.00"),
new BigDecimal("0.50"), new BigDecimal("0.20"), new BigDecimal("0.10"),
new BigDecimal("0.05"), new BigDecimal("0.01"));
private static final int[] DEFAULT_QUANTITIES = {400, 100, 200, 50, 80, 100, 0, 0, 0, 0, 0};
private static final BigDecimal DEFAULT_TOTAL_AMOUNT = new BigDecimal("50000.00");
public DenominationPlanView defaultPlan() {
List<DenominationItemRequest> items = new ArrayList<DenominationItemRequest>();
for (int index = 0; index < STANDARD_DENOMINATIONS.size(); index++) {
DenominationItemRequest item = new DenominationItemRequest();
item.setDenomination(STANDARD_DENOMINATIONS.get(index));
item.setQuantity(DEFAULT_QUANTITIES[index]);
items.add(item);
}
return validate(DEFAULT_TOTAL_AMOUNT, items);
}
public DenominationPlanView validate(BigDecimal totalAmount, List<DenominationItemRequest> items) {
BigDecimal requestedTotal = normalizeAmount(totalAmount);
Map<BigDecimal, Integer> quantities = quantitiesByDenomination(items);
List<DenominationPlanItemView> planItems = new ArrayList<DenominationPlanItemView>();
BigDecimal calculatedTotal = BigDecimal.ZERO.setScale(2);
int totalQuantity = 0;
for (BigDecimal denomination : STANDARD_DENOMINATIONS) {
int quantity = quantities.get(denomination);
BigDecimal subtotal = denomination.multiply(BigDecimal.valueOf(quantity)).setScale(2);
planItems.add(new DenominationPlanItemView(denomination, quantity, subtotal));
calculatedTotal = calculatedTotal.add(subtotal);
totalQuantity = Math.addExact(totalQuantity, quantity);
}
return new DenominationPlanView(planItems, totalQuantity, calculatedTotal,
calculatedTotal.compareTo(requestedTotal) == 0);
}
private Map<BigDecimal, Integer> quantitiesByDenomination(List<DenominationItemRequest> items) {
if (items == null || items.size() != STANDARD_DENOMINATIONS.size()) {
throw validationError("items must contain every standard denomination");
}
Map<BigDecimal, Integer> quantities = new HashMap<BigDecimal, Integer>();
for (DenominationItemRequest item : items) {
if (item == null || item.getDenomination() == null || item.getQuantity() == null) {
throw validationError("denomination item must include denomination and quantity");
}
BigDecimal denomination = normalizeDenomination(item.getDenomination());
if (!STANDARD_DENOMINATIONS.contains(denomination) || item.getQuantity() < 0
|| quantities.put(denomination, item.getQuantity()) != null) {
throw validationError("denomination plan contains an invalid item");
}
}
if (quantities.size() != STANDARD_DENOMINATIONS.size()) {
throw validationError("denomination plan must not omit a standard denomination");
}
return quantities;
}
private BigDecimal normalizeAmount(BigDecimal totalAmount) {
if (totalAmount == null) {
throw validationError("totalAmount must not be null");
}
try {
BigDecimal amount = totalAmount.setScale(2, RoundingMode.UNNECESSARY);
if (amount.signum() <= 0) {
throw validationError("totalAmount must be positive");
}
return amount;
} catch (ArithmeticException exception) {
throw validationError("totalAmount must have at most two decimal places");
}
}
private BigDecimal normalizeDenomination(BigDecimal denomination) {
try {
return denomination.setScale(2, RoundingMode.UNNECESSARY);
} catch (ArithmeticException exception) {
throw validationError("denomination must have at most two decimal places");
}
}
private BusinessException validationError(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
}
}

@ -1,24 +1,32 @@
package com.yau.digitalrmb.issuance.domain.model;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
public final class DenominationItem {
private final int denomination;
private final BigDecimal denomination;
private final int quantity;
public DenominationItem(int denomination, int quantity) {
if (denomination <= 0) {
this(BigDecimal.valueOf(denomination), quantity);
}
public DenominationItem(BigDecimal denomination, int quantity) {
BigDecimal normalizedDenomination = Objects.requireNonNull(denomination, "denomination must not be null")
.setScale(2, RoundingMode.UNNECESSARY);
if (normalizedDenomination.signum() <= 0) {
throw new IllegalArgumentException("denomination must be positive");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
this.denomination = denomination;
this.denomination = normalizedDenomination;
this.quantity = quantity;
}
public int getDenomination() {
public BigDecimal getDenomination() {
return denomination;
}
@ -35,11 +43,11 @@ public final class DenominationItem {
return false;
}
DenominationItem that = (DenominationItem) other;
return denomination == that.denomination && quantity == that.quantity;
return denomination.compareTo(that.denomination) == 0 && quantity == that.quantity;
}
@Override
public int hashCode() {
return Objects.hash(denomination, quantity);
return Objects.hash(denomination.stripTrailingZeros(), quantity);
}
}

@ -208,10 +208,10 @@ public class IssuanceRequest {
if (copiedDenominations.isEmpty()) {
throw new IllegalArgumentException("denominations must not be empty");
}
Set<Integer> faceValues = new HashSet<Integer>();
Set<BigDecimal> faceValues = new HashSet<BigDecimal>();
for (DenominationItem denomination : copiedDenominations) {
Objects.requireNonNull(denomination, "denomination item must not be null");
if (!faceValues.add(denomination.getDenomination())) {
if (!faceValues.add(denomination.getDenomination().stripTrailingZeros())) {
throw new IllegalArgumentException("denomination values must be unique");
}
}
@ -221,7 +221,7 @@ public class IssuanceRequest {
private BigDecimal calculateDenominationTotal(List<DenominationItem> denominations) {
BigDecimal total = BigDecimal.ZERO;
for (DenominationItem denomination : denominations) {
total = total.add(BigDecimal.valueOf(denomination.getDenomination())
total = total.add(denomination.getDenomination()
.multiply(BigDecimal.valueOf(denomination.getQuantity())));
}
return total.setScale(2);

@ -28,7 +28,7 @@ public final class IssuanceMessageComposer {
Collections.sort(orderedDenominations, new Comparator<DenominationItem>() {
@Override
public int compare(DenominationItem left, DenominationItem right) {
return Integer.compare(right.getDenomination(), left.getDenomination());
return right.getDenomination().compareTo(left.getDenomination());
}
});
@ -37,7 +37,7 @@ public final class IssuanceMessageComposer {
if (denominationText.length() > 0) {
denominationText.append(',');
}
denominationText.append(denomination.getDenomination())
denominationText.append(denomination.getDenomination().stripTrailingZeros().toPlainString())
.append(':')
.append(denomination.getQuantity());
}

@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("issuance_request_denomination")
@ -14,7 +16,7 @@ public class IssuanceRequestDenominationEntity {
@TableId(value = "request_id", type = IdType.INPUT)
private String requestId;
private Integer denomination;
private BigDecimal denomination;
private Integer quantity;
}

@ -2,14 +2,16 @@ package com.yau.digitalrmb.issuance.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
@Schema(description = "数字货币面额明细")
public class DenominationItemRequest {
@Schema(description = "面额", example = "100")
private Integer denomination;
private BigDecimal denomination;
@Schema(description = "数量", example = "400")
private Integer quantity;
public Integer getDenomination() { return denomination; }
public void setDenomination(Integer denomination) { this.denomination = denomination; }
public BigDecimal getDenomination() { return denomination; }
public void setDenomination(BigDecimal denomination) { this.denomination = denomination; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

@ -0,0 +1,14 @@
package com.yau.digitalrmb.issuance.interfaces.dto;
import java.math.BigDecimal;
import java.util.List;
public class DenominationPlanRequest {
private BigDecimal totalAmount;
private List<DenominationItemRequest> items;
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public List<DenominationItemRequest> getItems() { return items; }
public void setItems(List<DenominationItemRequest> items) { this.items = items; }
}

@ -3,11 +3,14 @@ package com.yau.digitalrmb.issuance.interfaces.rest;
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.query.CommercialBankInventoryView;
import com.yau.digitalrmb.issuance.application.query.DenominationPlanView;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.application.service.CommercialBankIssuanceApplicationService;
import com.yau.digitalrmb.issuance.application.service.DenominationPlanService;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.interfaces.dto.CreateIssuanceRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.DenominationPlanRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.UpdateIssuanceRequest;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
@ -19,16 +22,26 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/commercial-banks/issuance")
@Tag(name="数字货币发行模块 - 商业银行端", description="商业银行发起和处理数字货币发行申请")
public class CommercialBankIssuanceController {
private final CommercialBankIssuanceApplicationService service;
public CommercialBankIssuanceController(CommercialBankIssuanceApplicationService service){this.service=service;}
private final DenominationPlanService denominationPlanService;
public CommercialBankIssuanceController(CommercialBankIssuanceApplicationService service,
DenominationPlanService denominationPlanService){
this.service=service;
this.denominationPlanService=denominationPlanService;
}
@GetMapping("/inventory") @Operation(summary="查询货币需求信息", description="商业银行端查询库存余额、预警阈值和建议补充金额")
public ApiResponse<CommercialBankInventoryView> inventory(){return ok(service.getInventory());}
@GetMapping("/denomination-plan/default") @Operation(summary="获取默认面额结构", description="返回原型预置的11种标准面额、拆解数量和自动计算结果")
public ApiResponse<DenominationPlanView> defaultDenominationPlan(){return ok(denominationPlanService.defaultPlan());}
@PostMapping("/denomination-plan/validate") @Operation(summary="校验定制面额结构", description="自动计算小计、总张数和总金额,并返回是否等于申请总额")
public ApiResponse<DenominationPlanView> validateDenominationPlan(@RequestBody DenominationPlanRequest request){
return ok(denominationPlanService.validate(request.getTotalAmount(), request.getItems()));
}
@PostMapping("/requests") @Operation(summary="创建发行申请", description="商业银行端创建数字货币发行申请草稿")
public ApiResponse<IssuanceRequestView> create(@Valid @RequestBody CreateIssuanceRequest request,@AuthenticationPrincipal Jwt jwt){return ok(service.create(new CreateIssuanceRequestCommand(request.getBankCode(),request.getOrganizationId(),request.getTotalAmount(),request.getCurrency(),items(request.getDenominations())),actor(jwt)));}
@PutMapping("/requests/{id}") @Operation(summary="更新发行申请", description="商业银行端仅可更新草稿状态的发行申请")
@ -39,7 +52,16 @@ public class CommercialBankIssuanceController {
@PostMapping("/requests/{id}/sign") @Operation(summary="生成SM2签名",description="使用实验密钥sm2-key-02生成SM2签名") public ApiResponse<IssuanceRequestView> sign(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.sign(id,actor(jwt)));}
@PostMapping("/requests/{id}/package") @Operation(summary="封装发行请求报文",description="封装包含摘要与签名的JSON发行请求报文") public ApiResponse<IssuanceRequestView> pack(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.packagePayload(id,actor(jwt)));}
@PostMapping("/requests/{id}/send") @Operation(summary="发送发行请求",description="发送至中央银行端并更新中央银行接收状态") public ApiResponse<IssuanceRequestView> send(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.send(id,actor(jwt)));}
private List<DenominationItem> items(List<DenominationItemRequest> input){return input.stream().map(i->new DenominationItem(i.getDenomination(),i.getQuantity())).collect(Collectors.toList());}
private List<DenominationItem> items(List<DenominationItemRequest> input){
List<DenominationItem> result = new ArrayList<DenominationItem>();
for (DenominationItemRequest item : input) {
if (item.getQuantity() != null && item.getQuantity() == 0) {
continue;
}
result.add(new DenominationItem(item.getDenomination(), item.getQuantity()));
}
return result;
}
private String actor(Jwt jwt){String account=jwt.getClaimAsString("preferred_username"); return account==null||account.trim().isEmpty()?jwt.getSubject():account;}
private <T> ApiResponse<T> ok(T data){return ApiResponse.success(data,MDC.get(TraceIdFilter.MDC_KEY));}
}

@ -110,7 +110,7 @@ CREATE TABLE IF NOT EXISTS issuance_request (
CREATE TABLE IF NOT EXISTS issuance_request_denomination (
request_id CHAR(36) NOT NULL,
denomination INT NOT NULL,
denomination DECIMAL(10, 2) NOT NULL,
quantity INT NOT NULL,
PRIMARY KEY (request_id, denomination)
);

@ -62,6 +62,45 @@ class IssuanceControllerTest {
.andExpect(jsonPath("$.data.suggestedSupplementAmount").value(50000.00));
}
@Test
void returnsThePrototypeDefaultDenominationPlan() throws Exception {
mockMvc.perform(get("/api/v1/commercial-banks/issuance/denomination-plan/default")
.with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items.length()").value(11))
.andExpect(jsonPath("$.data.items[0].denomination").value(100))
.andExpect(jsonPath("$.data.items[0].quantity").value(400))
.andExpect(jsonPath("$.data.items[0].subtotal").value(40000.00))
.andExpect(jsonPath("$.data.totalQuantity").value(930))
.andExpect(jsonPath("$.data.totalAmount").value(50000.00))
.andExpect(jsonPath("$.data.valid").value(true));
}
@Test
void validatesDenominationPlanTotalsForTheConfirmationAction() throws Exception {
String requestBody = "{\"totalAmount\":50000.00,\"items\":[{\"denomination\":100,\"quantity\":400},{\"denomination\":50,\"quantity\":100},{\"denomination\":20,\"quantity\":200},{\"denomination\":10,\"quantity\":50},{\"denomination\":5,\"quantity\":80},{\"denomination\":1,\"quantity\":100},{\"denomination\":0.5,\"quantity\":0},{\"denomination\":0.2,\"quantity\":0},{\"denomination\":0.1,\"quantity\":0},{\"denomination\":0.05,\"quantity\":0},{\"denomination\":0.01,\"quantity\":0}]}";
mockMvc.perform(post("/api/v1/commercial-banks/issuance/denomination-plan/validate")
.with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001")))
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalQuantity").value(930))
.andExpect(jsonPath("$.data.totalAmount").value(50000.00))
.andExpect(jsonPath("$.data.valid").value(true));
}
@Test
void createsAnIssuanceRequestWhenThePlanIncludesZeroQuantityRows() throws Exception {
String requestBody = "{\"bankCode\":\"BKCHCNBJ00001\",\"organizationId\":\"ORG_3A4B5C6D7E8F\",\"totalAmount\":50000.00,\"currency\":\"DC\",\"denominations\":[{\"denomination\":100,\"quantity\":400},{\"denomination\":50,\"quantity\":100},{\"denomination\":20,\"quantity\":200},{\"denomination\":10,\"quantity\":50},{\"denomination\":5,\"quantity\":80},{\"denomination\":1,\"quantity\":100},{\"denomination\":0.5,\"quantity\":0},{\"denomination\":0.2,\"quantity\":0},{\"denomination\":0.1,\"quantity\":0},{\"denomination\":0.05,\"quantity\":0},{\"denomination\":0.01,\"quantity\":0}]}";
mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests")
.with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001")))
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andExpect(jsonPath("$.data.denominations.length()").value(6));
}
@Test
void swaggerDocumentsBothChineseIssuanceEnds() throws Exception {
String apiDocs = new String(mockMvc.perform(get("/v3/api-docs"))

Loading…
Cancel
Save