feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.finance.service.DeliveryFeeCoverageQueryService;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/internal/finance/v1/delivery-fee-coverage")
|
||||
public class InternalDeliveryFeeCoverageController {
|
||||
private final DeliveryFeeCoverageQueryService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping("/sources")
|
||||
public ApiResponse<List<DeliveryFeeCoverageModels.Fee>> sources(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody DeliveryFeeCoverageModels.Query request) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8)))
|
||||
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
|
||||
return FinanceResponses.success(service.query(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public final class DeliveryFeeCoverageModels {
|
||||
private DeliveryFeeCoverageModels() { }
|
||||
public record Query(@NotNull @Positive Long customerId,
|
||||
@NotEmpty @Size(max = 2000) List<@NotNull @Positive Long> inboundIds) { }
|
||||
public record Fee(long financeFeeId, long sourceFeeId, String sourceType, int sourceVersion,
|
||||
long customerId, long sourceInboundId, String inboundCode, String feeItemCode, String feeItemName,
|
||||
String currency, BigDecimal amount, String sourceHash, String status) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Finance eligibility only. The business operator must explicitly confirm which services are included. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeliveryFeeCoverageQueryService {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DeliveryFeeCoverageModels.Fee> query(DeliveryFeeCoverageModels.Query request) {
|
||||
return new NamedParameterJdbcTemplate(jdbc).query("""
|
||||
SELECT f.id,f.source_fee_id,f.source_type,f.source_fee_version,f.customer_id,f.inbound_id,
|
||||
CASE WHEN NULLIF(TRIM(f.inbound_code),'') IS NOT NULL THEN f.inbound_code
|
||||
WHEN f.source_type='INBOUND_FEE' THEN f.source_no ELSE NULL END AS coverage_inbound_code,
|
||||
f.fee_item_code,f.fee_item_name,f.currency_code,f.amount,f.source_hash,f.status
|
||||
FROM fin_fee_source f
|
||||
WHERE f.customer_id=:customerId AND f.inbound_id IN (:inboundIds)
|
||||
AND f.source_system='FIRSTLEG' AND f.source_type IN ('INBOUND_FEE','CONTAINER_FEE')
|
||||
AND f.direction='RECEIVABLE' AND f.review_status='APPROVED'
|
||||
AND f.status IN ('AVAILABLE','SETTLED')
|
||||
AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer
|
||||
WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type
|
||||
AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)
|
||||
ORDER BY f.inbound_id,f.fee_item_code,f.source_fee_id
|
||||
""", Map.of("customerId", request.customerId(), "inboundIds", request.inboundIds().stream().distinct().toList()),
|
||||
(row, index) -> new DeliveryFeeCoverageModels.Fee(row.getLong("id"), row.getLong("source_fee_id"),
|
||||
row.getString("source_type"), row.getInt("source_fee_version"), row.getLong("customer_id"), row.getLong("inbound_id"),
|
||||
row.getString("coverage_inbound_code"), row.getString("fee_item_code"), row.getString("fee_item_name"),
|
||||
row.getString("currency_code"), row.getBigDecimal("amount"), row.getString("source_hash"), row.getString("status")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
openapi: 3.0.3
|
||||
info: { title: Delivery fee coverage sources, version: 1.0.0 }
|
||||
paths:
|
||||
/internal/finance/v1/delivery-fee-coverage/sources:
|
||||
post:
|
||||
summary: 按实际客户及头程入仓单批量读取可引用的原费用版本
|
||||
description: 仅内部服务身份,Gateway不公开。只返回该客户、指定原入仓单、FIRSTLEG来源的已审核有效应收最新版本;AVAILABLE或SETTLED不代表自动涵盖任何尾程服务,服务范围由运营明确确认。查询不冻结资金、不变更账务、不新增收费。
|
||||
parameters:
|
||||
- { in: header, name: X-Internal-Service-Token, required: true, schema: { type: string } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [customerId, inboundIds]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
inboundIds: { type: array, minItems: 1, maxItems: 2000, items: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
responses:
|
||||
'200':
|
||||
description: 原费用不可变来源版本;没有符合记录返回空数组
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/FeeSource' } }
|
||||
'400': { description: 请求字段无效 }
|
||||
'401': { description: 内部服务令牌无效 }
|
||||
components:
|
||||
schemas:
|
||||
FeeSource:
|
||||
type: object
|
||||
properties:
|
||||
financeFeeId: { type: string }
|
||||
sourceFeeId: { type: string }
|
||||
sourceType: { type: string, enum: [INBOUND_FEE, CONTAINER_FEE] }
|
||||
sourceVersion: { type: integer }
|
||||
customerId: { type: string }
|
||||
sourceInboundId: { type: string }
|
||||
inboundCode: { type: string, nullable: true }
|
||||
feeItemCode: { type: string }
|
||||
feeItemName: { type: string }
|
||||
currency: { type: string }
|
||||
amount: { type: number }
|
||||
sourceHash: { type: string }
|
||||
status: { type: string, enum: [AVAILABLE, SETTLED] }
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.finance.service.DeliveryFeeCoverageQueryService;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class InternalDeliveryFeeCoverageControllerTest {
|
||||
@Test void requiresTheInternalIdentityBeforeQueryingFinancialData() {
|
||||
var service = mock(DeliveryFeeCoverageQueryService.class);
|
||||
var controller = new InternalDeliveryFeeCoverageController(service);
|
||||
ReflectionTestUtils.setField(controller, "expectedToken", "coverage-test-token");
|
||||
var query = new DeliveryFeeCoverageModels.Query(101L, List.of(11L));
|
||||
assertThatThrownBy(() -> controller.sources(null, query)).isInstanceOf(FinanceEventException.class);
|
||||
assertThatThrownBy(() -> controller.sources("wrong-token", query)).isInstanceOf(FinanceEventException.class);
|
||||
verifyNoInteractions(service);
|
||||
when(service.query(query)).thenReturn(List.of());
|
||||
assertThat(controller.sources("coverage-test-token", query).successful()).isTrue();
|
||||
verify(service).query(query);
|
||||
}
|
||||
@Test void validatesCustomerAndInboundScope() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(0L, List.of()))).hasSize(2);
|
||||
assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(101L, List.of(-1L)))).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DeliveryFeeCoverageQueryServiceTest {
|
||||
JdbcTemplate jdbc;
|
||||
DeliveryFeeCoverageQueryService service;
|
||||
@BeforeEach void setup() {
|
||||
jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_fee_id BIGINT,source_type VARCHAR(32),source_fee_version INT,customer_id BIGINT,inbound_id BIGINT,source_no VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),status VARCHAR(16),fee_item_code VARCHAR(64),fee_item_name VARCHAR(128),currency_code VARCHAR(3),amount DECIMAL(20,6),source_hash VARCHAR(71),inbound_code VARCHAR(64))");
|
||||
service = new DeliveryFeeCoverageQueryService(jdbc);
|
||||
}
|
||||
@Test void returnsOnlyThisCustomersActualInboundLatestApprovedReceivables() {
|
||||
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
insert(3, 702, 1, 202, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
insert(4, 703, 1, 101, 11, "FIRSTLEG", "PAYABLE", "APPROVED", "AVAILABLE");
|
||||
insert(5, 704, 1, 101, 11, "LASTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
insert(6, 705, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "PENDING", "AVAILABLE");
|
||||
insert(7, 706, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED");
|
||||
insert(8, 707, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SUPERSEDED");
|
||||
insert(9, 708, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SETTLED");
|
||||
insert(10, 709, 1, 101, 12, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
var rows = service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L, 11L)));
|
||||
assertThat(rows).extracting(DeliveryFeeCoverageModels.Fee::financeFeeId).containsExactly(2L, 9L);
|
||||
assertThat(rows).allSatisfy(row -> { assertThat(row.customerId()).isEqualTo(101); assertThat(row.sourceInboundId()).isEqualTo(11); assertThat(row.sourceHash()).startsWith("sha256:"); });
|
||||
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(999L)))).isEmpty();
|
||||
}
|
||||
@Test void aVoidedLatestVersionCannotFallBackToAnOlderActiveVersion() {
|
||||
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED");
|
||||
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L)))).isEmpty();
|
||||
}
|
||||
@Test void aContainerBusinessNumberIsNotMislabelledAsAnInboundCode() {
|
||||
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
|
||||
jdbc.update("UPDATE fin_fee_source SET source_type='CONTAINER_FEE',source_no='MSCU1234567'");
|
||||
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L))).get(0).inboundCode()).isNull();
|
||||
}
|
||||
void insert(long id, long fee, int version, long customer, long inbound, String system, String direction, String review, String status) {
|
||||
jdbc.update("INSERT INTO fin_fee_source VALUES(?, ?, ?, 'INBOUND_FEE', ?, ?, ?, ?, ?, ?, ?, 'FREIGHT', '原运费', 'CNY', 100, ?, NULL)",
|
||||
id, system, fee, version, customer, inbound, "IN-" + inbound, direction, review, status, "sha256:" + "a".repeat(64));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user