优化
This commit is contained in:
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
|
||||
+4
-7
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+31
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
}
|
||||
+56
@@ -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) {}
|
||||
}
|
||||
+28
@@ -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}
|
||||
+50
@@ -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"));
|
||||
}
|
||||
}
|
||||
+44
@@ -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/**
|
||||
|
||||
+20
@@ -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)
|
||||
|
||||
@@ -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:")
|
||||
|
||||
+4
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
@@ -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());
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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.')
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user