This commit is contained in:
2026-08-13 12:22:31 +08:00
parent 16dc03a2e2
commit e742abd1cf
49 changed files with 821 additions and 50 deletions
@@ -14697,6 +14697,12 @@ WHERE r.tenant_id=0 AND r.status='ACTIVE' AND r.deleted=0
AND ((r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:notification:read','platform:notification:read-all'))
OR (r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND p.permission_code IN('merchant:notification:read','merchant:notification:read-all')));
-- 开发期不保留独立详情权限:列表权限是唯一只读权限。
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:firstleg:ocean-booking:view','platform:firstleg:truck-appointment:view','platform:supplier:view');
UPDATE iam_permission SET status='INACTIVE',deleted=1
WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:firstleg:ocean-booking:view','platform:firstleg:truck-appointment:view','platform:supplier:view');
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
@@ -102,7 +102,7 @@ public class PlatformQualificationFileController {
}
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
@@ -82,11 +82,11 @@ class QualificationFileContractTest {
}
@Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod(
"download", AuthenticatedUser.class, long.class, String.class);
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
@@ -27,7 +27,7 @@ public class FinancePhase1OperationController {
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
@GetMapping("/accounts")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@@ -36,7 +36,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/accounts/{customerId}/ledger")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -47,7 +47,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:fee-source:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
}
@@ -41,7 +41,7 @@ public class FinanceSettlementController {
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view')")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')")
@Operation(summary = "查询正式应收结算单")
public SettlementListEnvelope receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -50,7 +50,7 @@ public class FinanceSettlementController {
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')")
@Operation(summary = "查询正式应付结算单")
public SettlementListEnvelope payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -77,17 +77,14 @@ public class FinanceSettlementController {
}
@GetMapping("/settlements/{settlementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view','platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')")
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId) {
SettlementDetail detail = service.getSettlement(settlementId);
String direction = detail.summary().direction();
String listPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list";
String legacyViewPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
if (user == null || (!user.permissions().contains(listPermission)
&& !user.permissions().contains(legacyViewPermission))) {
if (user == null || !user.permissions().contains(listPermission)) {
throw new AccessDeniedException("无权查看该方向结算单");
}
return success(detail);
@@ -45,14 +45,21 @@ public class PlatformFinanceController {
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/customer-accounts/{customerId}/summary")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户正式应收应付汇总")
public ApiResponse<?> customerAccountSummary(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccountSummary(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -62,7 +69,7 @@ public class PlatformFinanceController {
}
@GetMapping("/statements")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -87,7 +94,7 @@ public class PlatformFinanceController {
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@@ -115,11 +122,11 @@ public class PlatformFinanceController {
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@@ -36,7 +36,7 @@ public class ReconciliationStatementController {
}
@GetMapping("/reconciliation-statements/{statementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
return FinanceResponses.success(service.detailForPlatform(statementId));
}
@@ -3,6 +3,7 @@ package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
/** Stable read projections used by the Finance pages. */
@@ -18,6 +19,17 @@ public final class FinanceReadModels {
BigDecimal outstandingAmount,
List<AccountEntry> entries) {}
public record CustomerAccountSummary(
long customerId,
String currency,
BigDecimal totalReceivable,
BigDecimal receivedAmount,
BigDecimal outstandingReceivable,
BigDecimal pendingBilling,
BigDecimal outstandingPayable,
Instant asOf,
LocalDateTime lastChangedAt) {}
public record AccountEntry(
String entryType,
long resourceId,
@@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.dto.FinanceReadModels.BillRevision;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary;
import com.baiyue.finance.dto.FinanceReadModels.CostAllocation;
import com.baiyue.finance.dto.FinanceReadModels.CostDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
@@ -26,6 +27,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -58,6 +60,28 @@ public class FinanceQueryService {
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public CustomerAccountSummary customerAccountSummary(long customerId) {
requireCustomer(customerId);
String sql = "SELECT " +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," +
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.reviewStatus'))='APPROVED' " +
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.settlementIncluded'))='true' " +
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
"FROM fin_settlement s WHERE s.tenant_id=?";
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
customerId, customerId, customerId);
}
public List<FeePoolItem> feePool(Long customerId, String direction) {
validateOptionalCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
@@ -200,6 +224,11 @@ public class FinanceQueryService {
return value == null ? BigDecimal.ZERO : value;
}
private static BigDecimal money(ResultSet rs, String column) throws SQLException {
BigDecimal value = rs.getBigDecimal(column);
return value == null ? BigDecimal.ZERO : value;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
@@ -0,0 +1,100 @@
package com.baiyue.finance;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CustomerAccountSummaryBehaviorTest {
@Test
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
CustomerAccountSummary result = service.customerAccountSummary(9L);
assertThat(result.customerId()).isEqualTo(9L);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isEqualByComparingTo("300.00");
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
assertThat(result.pendingBilling()).isEqualByComparingTo("25.00");
assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00");
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
assertThat(sql.getValue())
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "'$.reviewStatus'", "'$.settlementIncluded'")
.contains("b.status<>'VOIDED'")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
}
@Test
void emptyCustomerReturnsZeroAmounts() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("0", "0", "0", "0", "0");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
assertThat(result.totalReceivable()).isZero();
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.outstandingPayable()).isZero();
}
@Test
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
FinanceQueryService query = mock(FinanceQueryService.class);
PlatformFinanceController controller = new PlatformFinanceController(mock(FinanceService.class),
mock(FinanceFileClient.class), mock(FirstlegCostSnapshotClient.class), query);
controller.customerAccountSummary(11L);
verify(query).customerAccountSummary(11L);
PreAuthorize permission = PlatformFinanceController.class
.getMethod("customerAccountSummary", long.class).getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo("hasAuthority('platform:finance:customer-account:list')");
}
private static ResultSet summaryRow(String total, String paid, String outstanding,
String pending, String payable) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending));
when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable));
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
return rs;
}
}
@@ -15,14 +15,14 @@ class FinancePhase1PermissionContractTest {
@Test
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
"platform:finance:customer-account:list", "platform:finance:account:view",
"platform:finance:customer-account:list", null,
int.class, int.class, String.class, String.class);
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
"platform:finance:customer-account:list", "platform:finance:account:view",
"platform:finance:customer-account:list", null,
long.class, int.class, int.class,
String.class, java.time.LocalDate.class, java.time.LocalDate.class);
assertEndpoint("getFinanceFeeSource", GetMapping.class,
"platform:finance:customer-arap:list", "platform:finance:fee-source:view", long.class);
"platform:finance:customer-arap:list", null, long.class);
assertEndpoint("reviewFinanceFeeSource", PostMapping.class,
"platform:finance:fee-source:review", null,
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
@@ -30,8 +30,8 @@ class FinancePlatformListContractTest {
Map<String, String> expected = Map.of(
"feePool", "platform:finance:customer-arap:list",
"statements", "platform:finance:customer-statement:list",
"receivableSettlements", "platform:finance:settlement-ar:view",
"payableSettlements", "platform:finance:settlement-ap:view");
"receivableSettlements", "platform:finance:settlement-ar:list",
"payableSettlements", "platform:finance:settlement-ap:list");
for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).contains(entry.getValue());
}
@@ -54,7 +54,7 @@ class FinanceSettlementBehaviorTest {
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0);
when(controllerService.getSettlement(88L)).thenReturn(payable);
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view"));
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list"));
var response = controller.getSettlement(apViewer, 88L);
@@ -62,7 +62,7 @@ class FinanceSettlementBehaviorTest {
assertThat(response.data()).isSameAs(payable);
assertThat(response.requestId()).isNotBlank();
assertThatThrownBy(() -> controller.getSettlement(
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class);
user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class);
}
@Test
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.customerstatistics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/customers")
public class PlatformCustomerTransitStatisticsController {
@Resource private CustomerTransitStatisticsService service;
@GetMapping("/{customerId}/container-transit-summary")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<TransitSummary> summary(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long customerId) {
FirstlegScopeGuard.platform(user, customerId);
return ApiResponse.success(service.summarize(customerId));
}
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.customerstatistics.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class CustomerTransitStatisticsDtos {
private CustomerTransitStatisticsDtos() {}
public record TransitSummary(Long customerId, long containerCount, BigDecimal totalTeu,
long unknownContainerCount, LocalDateTime asOf, String definition) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.customerstatistics.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper
public interface CustomerTransitStatisticsMapper {
@Select("""
SELECT #{customerId} AS customerId,
COUNT(*) AS containerCount,
COALESCE(SUM(teu), 0) AS totalTeu,
SUM(CASE WHEN teu IS NULL THEN 1 ELSE 0 END) AS unknownContainerCount,
CURRENT_TIMESTAMP(3) AS asOf
FROM (
SELECT DISTINCT x.customer_id, x.container_booking_id,
CASE
WHEN UPPER(TRIM(x.container_type)) LIKE '20%' THEN 1.0
WHEN UPPER(TRIM(x.container_type)) LIKE '40%' THEN 2.0
WHEN UPPER(TRIM(x.container_type)) LIKE '45%' THEN 2.25
ELSE NULL
END AS teu
FROM (
SELECT b.customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan p
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id
WHERE p.container_booking_id = c.id AND i.deleted = 0
)
UNION ALL
SELECT i.account_id AS customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
JOIN fl_loading_plan p ON p.container_booking_id = c.id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND i.account_id IS NOT NULL AND i.account_id > 0
) x
WHERE x.customer_id = #{customerId}
) scoped
""")
SummaryRow summarize(@Param("customerId") long customerId);
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.customerstatistics.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class CustomerTransitStatisticsService {
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
@Resource private CustomerTransitStatisticsMapper mapper;
public TransitSummary summarize(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
var row = mapper.summarize(customerId);
return new TransitSummary(customerId,
value(row == null ? null : row.containerCount()),
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
value(row == null ? null : row.unknownContainerCount()),
row == null ? null : row.asOf(), DEFINITION);
}
private static long value(Long value) { return value == null ? 0L : value; }
}
@@ -256,4 +256,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code='platform:firstleg:ocean-booking:view';
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:ocean-booking:view';
USE baiyue_firstleg;
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
KEY idx_container_booking_container (container_no),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
@@ -1175,6 +1176,12 @@ WHERE role.tenant_id = 0
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELETE rp FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1
WHERE permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
@@ -138,4 +138,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code='platform:firstleg:truck-appointment:view';
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:truck-appointment:view';
USE baiyue_firstleg;
@@ -0,0 +1,35 @@
openapi: 3.0.3
info:
title: Firstleg 客户统计 API
version: 1.0.0
paths:
/api/platform/v1/customers/{customerId}/container-transit-summary:
get:
summary: 查询客户在途柜量(TEU)
description: 需要 platform:firstleg:container:list;按客户+柜次去重,整柜按订舱客户归属,拼柜按 active LoadingPlanItem.accountId 归属。
parameters:
- {name: customerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: 成功或业务失败,响应体 code 为字符串 200 时成功
content:
application/json:
schema: {$ref: '#/components/schemas/TransitSummaryEnvelope'}
components:
schemas:
TransitSummaryEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/TransitSummary'}
TransitSummary:
type: object
required: [customerId, containerCount, totalTeu, unknownContainerCount, asOf, definition]
properties:
customerId: {type: integer, format: int64, minimum: 1}
containerCount: {type: integer, format: int64, minimum: 0}
totalTeu: {type: number, format: decimal, multipleOf: 0.01, minimum: 0, description: 已知箱型TEU合计;未知箱型不计入}
unknownContainerCount: {type: integer, format: int64, minimum: 0}
asOf: {type: string, format: date-time}
definition: {type: string}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTransitStatisticsContractTest {
@Test void exposesPlatformCustomerSummaryWithStablePermission() throws Exception {
assertThat(PlatformCustomerTransitStatisticsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customers");
var method = PlatformCustomerTransitStatisticsController.class.getDeclaredMethod(
"summary", com.baiyue.common.security.AuthenticatedUser.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{customerId}/container-transit-summary");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:container:list')");
}
@Test void queryEnforcesTransitScopeOwnershipDedupAndTeuRules() throws Exception {
var sql = Arrays.stream(CustomerTransitStatisticsMapper.class.getDeclaredMethods())
.findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value();
var statement = String.join("\n", sql);
assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL",
"status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id",
"b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS",
"x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0",
"LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL");
}
@Test void schemaAddsTransitSummaryIndexAndDefinition() throws Exception {
var schema = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema).contains("idx_container_booking_transit_summary");
var openApi = new ClassPathResource("openapi/customer-statistics-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openApi).contains("containerCount:", "format: int64", "totalTeu:",
"multipleOf: 0.01", "unknownContainerCount:", "format: date-time", "definition:");
var service = CustomerTransitStatisticsService.class;
assertThat(service.getDeclaredFields()).anyMatch(f -> f.getName().equals("DEFINITION"));
}
}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerTransitStatisticsServiceTest {
@Mock CustomerTransitStatisticsMapper mapper;
@InjectMocks CustomerTransitStatisticsService service;
@Test void preservesDecimalTeuAndUnknownCount() {
var asOf = LocalDateTime.of(2026, 8, 13, 12, 0);
when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow(
88L, 4L, new BigDecimal("6.25"), 1L, asOf));
var result = service.summarize(88L);
assertThat(result.customerId()).isEqualTo(88L);
assertThat(result.containerCount()).isEqualTo(4);
assertThat(result.totalTeu()).isEqualByComparingTo("6.25");
assertThat(result.unknownContainerCount()).isEqualTo(1);
assertThat(result.asOf()).isEqualTo(asOf);
assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型");
verify(mapper).summarize(88L);
}
@Test void rejectsInvalidCustomerBeforeQuery() {
assertThatThrownBy(() -> service.summarize(0)).isInstanceOf(BizException.class);
}
}
@@ -164,6 +164,7 @@ baiyue:
- /api/auth/login
- /api/auth/register
- /api/auth/refresh
- /api/auth/impersonations/exchange
- /openapi/v1/inbound-measurements/**
- /actuator/health
- /swagger-ui/**
@@ -0,0 +1,20 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class ImpersonationExchangeSecurityContractTest {
@Test
void permitsAnonymousImpersonationCodeExchangeAtTheGateway() throws Exception {
String yaml = new ClassPathResource("application.yml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("permit-paths:")
.contains("- /api/auth/impersonations/exchange");
}
}
@@ -69,7 +69,7 @@ public class PlatformTenantController {
}
@GetMapping("/tenants/{tenantId:\\d+}")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户档案", description = "返回企业基础资料、销售负责人、账期、注册地址以及入驻和审批时间。")
public ApiResponse<TenantResponse> tenant(@PathVariable long tenantId,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -89,7 +89,7 @@ public class PlatformTenantController {
}
@GetMapping("/tenants/{tenantId:\\d+}/members")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。")
public ApiResponse<List<PlatformCustomerMemberDtos.MemberResponse>> members(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -115,7 +115,7 @@ public class ProfileResourceController {
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。")
public ApiResponse<List<IamTenantQualification>> platformQualifications(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -124,7 +124,7 @@ public class ProfileResourceController {
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。")
public ApiResponse<List<IamTenantAddress>> platformAddresses(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -67,14 +67,14 @@ VALUES
(5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0),
(5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',0),
(5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
(5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',0),
(5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',0),
(5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), status='ACTIVE', deleted=0;
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
+4 -4
View File
@@ -89,7 +89,7 @@ paths:
get:
tags: [Platform Tenant / 平台租户]
summary: 查询企业客户档案
description: 需要独立的 `platform:customer:view` 权限,返回完整联系方式。
description: 需要 `platform:customer:list` 权限,返回完整联系方式。
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'403': {$ref: '#/components/responses/Forbidden'}
@@ -113,7 +113,7 @@ paths:
get:
tags: [Platform Tenant / 平台租户]
summary: 查询企业客户成员及账号
description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:view` 权限。
description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:list` 权限。
responses:
'200':
description: 企业成员查询成功
@@ -142,7 +142,7 @@ paths:
get:
tags: [Platform Tenant / 平台租户]
summary: 查询企业客户地址
description: 需要 `platform:customer:view` 权限;只读接口,不提供平台端地址写入。
description: 需要 `platform:customer:list` 权限;只读接口,不提供平台端地址写入。
responses:
'200':
description: 企业客户地址查询成功
@@ -157,7 +157,7 @@ paths:
get:
tags: [Platform Tenant / 平台租户]
summary: 查询企业客户资质
description: 需要 `platform:customer:view` 权限;只读查询资质元数据,不授予审核权限。
description: 需要 `platform:customer:list` 权限;只读查询资质元数据,不授予审核权限。
responses:
'200':
description: 企业客户资质查询成功
@@ -31,7 +31,8 @@ class IamOpenApiContractTest {
.contains("id: {type: string, pattern: '^\\d+$'")
.contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:")
.contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:")
.contains("platform:customer:view")
.contains("platform:customer:list")
.doesNotContain("platform:customer:view")
.contains("TenantAddressListApiResponse:")
.contains("TenantQualificationListApiResponse:")
.contains("InternalUserListApiResponse:")
@@ -13,17 +13,17 @@ class PlatformCustomerResourceContractTest {
@Test
void shouldHandleCase01() throws Exception {
assertReadOnlyViewContract("platformAddresses",
assertListPermissionContract("platformAddresses",
"/api/platform/v1/iam/tenants/{tenantId}/addresses");
assertReadOnlyViewContract("platformQualifications",
assertListPermissionContract("platformQualifications",
"/api/platform/v1/iam/tenants/{tenantId}/qualifications");
}
private void assertReadOnlyViewContract(String methodName, String path) throws Exception {
private void assertListPermissionContract(String methodName, String path) throws Exception {
Method method = ProfileResourceController.class.getMethod(
methodName, long.class, AuthenticatedUser.class);
assertEquals(path, method.getAnnotation(GetMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:view')",
assertEquals("hasAuthority('platform:customer:list')",
method.getAnnotation(PreAuthorize.class).value());
}
}
@@ -52,7 +52,7 @@ class PlatformTenantControllerContractTest {
void shouldHandleCase03() throws Exception {
Method method = PlatformTenantController.class.getMethod("tenant", long.class, AuthenticatedUser.class);
assertEquals("hasAuthority('platform:customer:view')",
assertEquals("hasAuthority('platform:customer:list')",
method.getAnnotation(PreAuthorize.class).value());
}
@@ -0,0 +1,27 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.controller.NotificationInboxController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class NotificationInboxPermissionContractTest {
@Test
void separatesReadOperationsFromInboxViewing() {
assertPermission("list", "hasAuthority(#app + ':notification:view')");
assertPermission("unread", "hasAuthority(#app + ':notification:view')");
assertPermission("detail", "hasAuthority(#app + ':notification:view')");
assertPermission("read", "hasAuthority(#app + ':notification:read')");
assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')");
}
private static void assertPermission(String methodName, String expected) {
var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
}
@@ -5,7 +5,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class NotificationSqlContractTest {
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","merchant:notification:view");}
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","platform:notification:read","platform:notification:read-all","merchant:notification:view","merchant:notification:read","merchant:notification:read-all").contains("'MENU',5106000,'platform:notification:template:list'","'MENU',5106000,'platform:notification:task:list'");}
@Test void unifiedMigrationContainsNotificationBaselineAndSevenIndexAssertion() throws Exception {
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
@@ -134,6 +135,16 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.countByTenantIds(tenantIds));
}
@GetMapping("/tenant-statistics")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "查询客户累计及本月运单数", description = "仅统计已提交的 MASTER 根运单;草稿和 CHILD 拆分单不计入,累计不受后续取消或驳回影响。月份按 Asia/Shanghai 半开区间计算。")
public ApiResponse<TenantOrderStatisticsResponse> tenantStatistics(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.tenantSubmittedStatistics(scopedTenant));
}
@Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -0,0 +1,14 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "TenantOrderStatisticsResponse", description = "平台客户已提交主运单统计")
public record TenantOrderStatisticsResponse(
@Schema(description = "客户 ID") Long tenantId,
@Schema(description = "历史累计已提交主运单数") Long totalSubmittedOrders,
@Schema(description = "当前上海时区月份已提交主运单数") Long currentMonthSubmittedOrders,
@Schema(description = "本月起始时间(含),ISO-8601") String monthStart,
@Schema(description = "下月起始时间(不含),ISO-8601") String nextMonthStart,
@Schema(description = "统计时区") String zoneId,
@Schema(description = "统计快照时间,ISO-8601") String asOf
) { }
@@ -1,4 +1,25 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 订单主表 Mapper,提供 {@code ord_order} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderMapper extends BaseMapper<OrderEntity>{}
public interface OrderMapper extends BaseMapper<OrderEntity>{
@Select("""
SELECT COUNT(*) AS total_submitted_orders,
COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart}
AND submitted_at < #{nextMonthStart}
THEN 1 ELSE 0 END), 0) AS current_month_submitted_orders
FROM ord_order
WHERE tenant_id = #{tenantId}
AND deleted = 0
AND order_role = 'MASTER'
AND submitted_at IS NOT NULL
""")
Map<String, Object> selectTenantSubmittedStatistics(
@Param("tenantId") long tenantId,
@Param("monthStart") LocalDateTime monthStart,
@Param("nextMonthStart") LocalDateTime nextMonthStart);
}
@@ -258,6 +258,15 @@ public class OrderRepository {
));
}
/** 一次条件聚合客户已提交主运单,取消/驳回状态不会影响累计值。 */
public Map<String, Object> tenantSubmittedStatistics(long tenantId,
LocalDateTime monthStart,
LocalDateTime nextMonthStart) {
Map<String, Object> aggregate = orderMapper.selectTenantSubmittedStatistics(
tenantId, monthStart, nextMonthStart);
return aggregate == null ? Map.of() : aggregate;
}
public List<OrderCargoEntity> findCargo(long tenantId, long orderId) {
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId)
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository;
@@ -23,6 +24,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.time.*;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@@ -154,6 +156,30 @@ public class OrderApplicationService {
.map(tenantId -> new TenantOrderCountResponse(tenantId, counts.getOrDefault(tenantId, 0L)))
.toList();
}
private static final ZoneId STATISTICS_ZONE = ZoneId.of("Asia/Shanghai");
public TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId) {
return tenantSubmittedStatistics(tenantId, Clock.system(STATISTICS_ZONE));
}
TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId, Clock clock) {
ZonedDateTime asOf = ZonedDateTime.now(clock).withZoneSameInstant(STATISTICS_ZONE);
YearMonth month = YearMonth.from(asOf);
ZonedDateTime monthStart = month.atDay(1).atStartOfDay(STATISTICS_ZONE);
ZonedDateTime nextMonthStart = month.plusMonths(1).atDay(1).atStartOfDay(STATISTICS_ZONE);
var aggregate = repository.tenantSubmittedStatistics(tenantId,
monthStart.toLocalDateTime(), nextMonthStart.toLocalDateTime());
long total = number(aggregate.get("total_submitted_orders"));
long current = number(aggregate.get("current_month_submitted_orders"));
return new TenantOrderStatisticsResponse(tenantId, total, current,
monthStart.toOffsetDateTime().toString(), nextMonthStart.toOffsetDateTime().toString(),
STATISTICS_ZONE.getId(), asOf.toOffsetDateTime().toString());
}
private static long number(Object value) {
return value instanceof Number n ? n.longValue() : 0L;
}
public OrderDetailResponse detail(long tenantId, long id) {
OrderEntity order = require(tenantId, id);
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
+1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code),
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at),
KEY idx_order_tenant_customer (tenant_id, customer_id),
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
@@ -231,6 +231,25 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
/api/platform/v1/orders/first-leg/tenant-statistics:
get:
tags: [Platform first-leg orders]
summary: 查询客户累计及本月运单数
description: 仅统计 submitted_at 非空的 MASTER 根运单,草稿和 CHILD 拆分单不计入;累计不受取消或驳回影响;月份按 Asia/Shanghai 半开区间计算。
parameters:
- {$ref: '#/components/parameters/TenantId'}
responses:
'200':
description: 成功;需要 platform:order:list 权限
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/TenantOrderStatisticsResponse'}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders/first-leg/tenant-counts:
get:
summary: 批量查询客户运单数
@@ -327,6 +346,18 @@ paths:
'403': {description: 需要 merchant:order:view 权限}
components:
schemas:
TenantOrderStatisticsResponse:
type: object
required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: Long 按字符串序列化以避免 JavaScript 精度丢失}
totalSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化}
currentMonthSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化}
monthStart: {type: string, format: date-time}
nextMonthStart: {type: string, format: date-time}
zoneId: {type: string, example: Asia/Shanghai}
asOf: {type: string, format: date-time}
securitySchemes:
bearerAuth:
type: http
@@ -0,0 +1,24 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class TenantOrderStatisticsControllerContractTest {
@Test
void endpointUsesStablePathListPermissionAndResponseType() throws Exception {
Method method = PlatformFirstLegOrderController.class.getMethod("tenantStatistics",
com.baiyue.common.security.AuthenticatedUser.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/tenant-statistics");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:order:list')");
assertThat(method.getGenericReturnType().getTypeName())
.contains(TenantOrderStatisticsResponse.class.getName());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.order.repository;
import com.baiyue.order.mapper.OrderMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class TenantOrderStatisticsSqlContractTest {
@Test
void oneConditionalAggregateEnforcesTenantSubmittedMasterAndHalfOpenMonth() throws Exception {
Method method = OrderMapper.class.getMethod("selectTenantSubmittedStatistics",
long.class, java.time.LocalDateTime.class, java.time.LocalDateTime.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value())
.replaceAll("\\s+", " ").toLowerCase();
assertThat(sql)
.contains("count(*) as total_submitted_orders")
.contains("sum(case when submitted_at >= #{monthstart}")
.contains("submitted_at < #{nextmonthstart}")
.contains("tenant_id = #{tenantid}")
.contains("deleted = 0")
.contains("order_role = 'master'")
.contains("submitted_at is not null")
.doesNotContain("created_at")
.doesNotContain("status =")
.doesNotContain("status in");
}
}
@@ -0,0 +1,68 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class})
class TenantOrderStatisticsServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
void usesShanghaiHalfOpenCalendarMonthAndReturnsAggregate() {
Clock clock = Clock.fixed(Instant.parse("2026-08-31T16:30:00Z"), ZoneId.of("UTC"));
when(repository.tenantSubmittedStatistics(88L,
LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 10, 1, 0, 0)))
.thenReturn(Map.of("total_submitted_orders", 12L,
"current_month_submitted_orders", 3L));
TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(88L, clock);
assertThat(response).isEqualTo(new TenantOrderStatisticsResponse(
88L, 12L, 3L, "2026-09-01T00:00+08:00", "2026-10-01T00:00+08:00",
"Asia/Shanghai", "2026-09-01T00:30+08:00"));
verify(repository).tenantSubmittedStatistics(88L,
LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 10, 1, 0, 0));
}
@Test
void returnsZeroWhenNoSubmittedMasterOrdersExist() {
Clock clock = Clock.fixed(Instant.parse("2026-01-15T00:00:00Z"), ZoneId.of("UTC"));
when(repository.tenantSubmittedStatistics(99L,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 2, 1, 0, 0))).thenReturn(Map.of());
TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(99L, clock);
assertThat(response.totalSubmittedOrders()).isZero();
assertThat(response.currentMonthSubmittedOrders()).isZero();
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -0,0 +1,39 @@
const MODULE = 'customer-operating-statistics'
const OPTIONS = { scenario: 'customer-detail-operating-statistics', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function decimal(value) { return typeof value === 'number' || (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) }
function nonNegative(value) { return decimal(value) && Number(value) >= 0 }
export async function run(context) {
const customerId = id(context.context.customerId || context.createdResources.customer?.tenantId)
context.assert(MODULE, '取得客户详情统计客户隔离键', null, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS)
if (!customerId) return
const orderPath = `/api/platform/v1/orders/first-leg/tenant-statistics?tenantId=${encodeURIComponent(customerId)}`
const orderResponse = await context.request('GET', orderPath)
const orders = context.apiSuccess(MODULE, '查询累计与本月已提交主运单统计', orderResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '运单统计返回上海时区半开月份边界与客户隔离键', orderResponse,
id(orders?.tenantId) === customerId && nonNegative(orders?.totalSubmittedOrders)
&& nonNegative(orders?.currentMonthSubmittedOrders) && orders?.zoneId === 'Asia/Shanghai'
&& Boolean(orders?.monthStart) && Boolean(orders?.nextMonthStart) && Boolean(orders?.asOf),
`tenantId=${id(orders?.tenantId) || 'missing'}, zoneId=${orders?.zoneId || 'missing'}`, OPTIONS)
const financePath = `/api/platform/finance/v1/customer-accounts/${encodeURIComponent(customerId)}/summary`
const financeResponse = await context.request('GET', financePath)
const finance = context.apiSuccess(MODULE, '查询非VOIDED正式结算应收汇总', financeResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '应收汇总保持CNY十进制金额与客户隔离键', financeResponse,
id(finance?.customerId) === customerId && finance?.currency === 'CNY'
&& ['totalReceivable', 'receivedAmount', 'outstandingReceivable'].every((field) => nonNegative(finance?.[field]))
&& Number(finance?.outstandingReceivable) <= Number(finance?.totalReceivable),
`customerId=${id(finance?.customerId) || 'missing'}, currency=${finance?.currency || 'missing'}`, OPTIONS)
const transitPath = `/api/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary`
const transitResponse = await context.request('GET', transitPath)
const transit = context.apiSuccess(MODULE, '查询已开船未到仓在途柜量', transitResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '在途柜量保持TEU小数、未知柜型与客户隔离键', transitResponse,
id(transit?.customerId) === customerId && nonNegative(transit?.containerCount)
&& nonNegative(transit?.totalTeu) && nonNegative(transit?.unknownContainerCount)
&& Number(transit?.unknownContainerCount) <= Number(transit?.containerCount) && Boolean(transit?.definition),
`customerId=${id(transit?.customerId) || 'missing'}, totalTeu=${transit?.totalTeu ?? 'missing'}, unknown=${transit?.unknownContainerCount ?? 'missing'}`, OPTIONS)
}
@@ -7,6 +7,13 @@ import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8')
const operatingStatistics = fs.readFileSync(path.join(here, 'customer-operating-statistics.mjs'), 'utf8')
const repositoryRoot = path.join(here, '..', '..')
const orderStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'order', 'src', 'test', 'java', 'com', 'baiyue', 'order', 'service', 'TenantOrderStatisticsServiceTest.java'), 'utf8')
+ fs.readFileSync(path.join(repositoryRoot, 'order', 'src', 'test', 'java', 'com', 'baiyue', 'order', 'repository', 'TenantOrderStatisticsSqlContractTest.java'), 'utf8')
const financeStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'finance', 'src', 'test', 'java', 'com', 'baiyue', 'finance', 'CustomerAccountSummaryBehaviorTest.java'), 'utf8')
const transitStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'firstleg', 'src', 'test', 'java', 'com', 'baiyue', 'firstleg', 'customerstatistics', 'CustomerTransitStatisticsServiceTest.java'), 'utf8')
+ fs.readFileSync(path.join(repositoryRoot, 'firstleg', 'src', 'test', 'java', 'com', 'baiyue', 'firstleg', 'customerstatistics', 'CustomerTransitStatisticsContractTest.java'), 'utf8')
test('客户验收覆盖审核资质上传、回查和鉴权下载', () => {
assert.match(acceptance, /files\/qualifications\/\$\{tenantId\}\/required-package/)
@@ -30,3 +37,22 @@ test('客户验收覆盖客户维度操作日志列表和详情快照', () => {
assert.match(acceptance, /JSON\.parse\(auditDetail\[field\]\)/)
assert.match(acceptance, /snapshotsAreRedacted/)
})
test('客户详情经营统计模块登记三项独立正式接口与精度字段', () => {
assert.match(acceptance, /'customer-operating-statistics'/)
assert.match(operatingStatistics, /orders\/first-leg\/tenant-statistics\?tenantId=/)
assert.match(operatingStatistics, /customer-accounts\/\$\{encodeURIComponent\(customerId\)\}\/summary/)
assert.match(operatingStatistics, /container-transit-summary/)
for (const field of ['currentMonthSubmittedOrders', 'outstandingReceivable', 'totalTeu', 'unknownContainerCount']) {
assert.match(operatingStatistics, new RegExp(field))
}
})
test('后端统计契约测试覆盖客户详情已确认边界口径', () => {
for (const marker of ["order_role = 'master'", 'submitted_at is not null', 'monthstart', 'nextmonthstart', 'doesNotContain\\\("status ="\\\)']) assert.match(orderStatisticsTest, new RegExp(marker, 'i'))
for (const marker of ['VOIDED', 'totalReceivable', 'receivedAmount', 'outstandingReceivable']) assert.match(financeStatisticsTest, new RegExp(marker, 'i'))
for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'FAILED', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i'))
assert.match(orderStatisticsTest, /tenant_id|tenantSubmittedStatistics/)
assert.match(financeStatisticsTest, /customerId|customer_id/)
assert.match(transitStatisticsTest, /customerId|customer_id/)
})
+2 -2
View File
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
$ExpectedSha256 = 'EB965FCBCE23B7A6E565530C13188B1C55FAB43B8FD4E3408DED91D57454A7DF'
$ExpectedLines = 14456
$ExpectedSha256 = 'CA250057CAB91D248E31BCC5A66CADE72F81EB9906B19B8E3CB09BD60B112F29'
$ExpectedLines = 14708
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*;
public class PlatformCustomerOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
public ApiResponse<OverseasStorageFeeDtos.View> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); }
@PutMapping