fix(procurement): reject unsupported purchase payload fields

This commit is contained in:
2026-10-10 14:44:23 +08:00
parent 25aae44918
commit 331f750871
4 changed files with 83 additions and 1 deletions
@@ -28,7 +28,12 @@ public final class OverseasProcurementRequests {
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
@NotBlank @Size(max = 255) String idempotencyKey) {
@com.fasterxml.jackson.annotation.JsonAnySetter
public void rejectUnknownField(String name, JsonNode value) {
throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name);
}
}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
@@ -10,6 +10,29 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*;
class ProcurementRequestBoundaryTest {
@Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() {
new org.springframework.boot.test.context.runner.ApplicationContextRunner()
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class))
.withUserConfiguration(com.baiyue.common.web.LongIdJacksonConfiguration.class)
.run(context -> {
var json=context.getBean(ObjectMapper.class);
assertThat(json.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
String valid="""
{"purchaseCategory":"sample","productName":"Sample","productSpecification":"A",
"purchaseQuantity":1,"budgetUnitPrice":10,"currency":"CNY","receivingAddress":"Address",
"contactName":"Buyer","contactPhone":"13800000000","idempotencyKey":"one"}
""";
assertThat(json.readValue(valid,OverseasProcurementRequests.Create.class).productName()).isEqualTo("Sample");
for(String field:List.of("items","products","batches","purchaseBatches","shipmentBatches")) {
var extended=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(valid);
extended.putArray(field).addObject().put("productName","Second product");
assertThatThrownBy(()->json.treeToValue(extended,OverseasProcurementRequests.Create.class))
.as(field).hasMessageContaining("单商品");
}
});
}
@Test void missingCommandVersionCannotDefaultToZero() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
@@ -109,6 +109,30 @@ class ProcurementPhysicalPersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero();
}
@Test void nonPositiveMeasurementsAndOverPreciseWeightCannotPersistPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);
var source=List.of(new Source(receipt.id(),10));
for(BigDecimal invalid:List.of(BigDecimal.ZERO,BigDecimal.ONE.negate())) {
for(int axis=0;axis<4;axis++) {
BigDecimal[] values={BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE};values[axis]=invalid;
var carton=new Carton("C1",values[0],values[1],values[2],values[3],source);
assertThatThrownBy(()->pack(List.of(carton))).hasMessageContaining("正数");
}
}
assertThatThrownBy(()->pack(List.of(new Carton("C1",BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,new BigDecimal("1.0001"),source))))
.hasMessageContaining("最多三位");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero();
}
@Test void foreignReceiptAndIncompleteAllocationCannotBecomeConfirmedPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(999,10))))).hasMessageContaining("不属于本单");
var draft=pack(List.of(carton("C1",new Source(receipt.id(),9))));
assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"incomplete",draft.packingVersion(),"checked"))))
.hasMessageContaining("全部");
assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("DRAFT");
}
@Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var first=pack(List.of(carton("C1",new Source(receipt.id(),10))));
@@ -113,6 +113,36 @@ class ProcurementPurchasePersistenceTest {
assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void unreviewedPurchaseFactCannotConfirmOrCharge() {
jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11");
var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(recorded.purchase().recorded()).isTrue();
assertThat(recorded.purchase().confirmed()).isFalse();
assertThat(recorded.purchase().issue()).contains("尚未审核");
var again=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-unreviewed","actual fact")));
assertThat(again.purchase().confirmed()).isFalse();
assertThat(again.status()).isEqualTo("PENDING_REVIEW");
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void missingBudgetCannotConfirmOrInventACharge() {
jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=NULL WHERE id=11");
var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(recorded.purchase().recorded()).isTrue();
assertThat(recorded.purchase().confirmed()).isFalse();
assertThat(recorded.purchase().issue()).contains("预算尚未明确");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80");
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void anotherPurchaseKeyCannotAppendOrOverwriteRecordedPurchase() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
var another=new Purchase(1L,"purchase-2","Sample","A",10,new BigDecimal("90"),"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(3),1,"additional purchase");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",another))).hasMessageContaining("不能再次购买");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80");
verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any());
}
@Test void cancellationCannotErasePurchasedFacts() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消");