diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c8c5d20ed..b8211eddc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15154,7 +15154,7 @@ DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permissio UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' -WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export'); +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; INSERT INTO baiyue_iam.iam_permission @@ -15373,15 +15373,18 @@ DROP PROCEDURE IF EXISTS assert_finance_permissions; DELIMITER $$ CREATE PROCEDURE assert_finance_permissions() BEGIN - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27 THEN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 28 THEN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; + END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整'; END IF; diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 6307427df..0de11cc3a 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -48,6 +48,7 @@ public class FinanceExportProviderService { private static final int SCHEMA_VERSION = 1; private static final int MAX_BATCH_SIZE = 500; + private static final int QUERY_PAGE_SIZE = 200; private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; private static final Set TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER, FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST, @@ -148,13 +149,15 @@ public class FinanceExportProviderService { int limit = request.source().limit(); Map p = request.parameters(); if (CUSTOMER_ACCOUNT_LIST.equals(type)) { - int page = offset / limit + 1; - var result = accountService.list(page, limit, null, text(p, "status"), longValue(p, "customerId", false)); + int querySize = Math.min(limit, QUERY_PAGE_SIZE); + int page = offset / querySize + 1; + var result = accountService.list(page, querySize, null, text(p, "status"), longValue(p, "customerId", false)); return new BatchRows(result.data().stream().map(this::accountRow).toList(), result.page().total()); } if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) { - int page = offset / limit + 1; - var result = operationService.listDerivedCustomerAccountLedger(longValue(p, "customerId", true), page, limit, + int querySize = Math.min(limit, QUERY_PAGE_SIZE); + int page = offset / querySize + 1; + var result = operationService.listDerivedCustomerAccountLedger(longValue(p, "customerId", true), page, querySize, text(p, "businessType"), date(p, "startDate"), date(p, "endDate")); return new BatchRows(result.data().stream().map(this::ledgerRow).toList(), result.page().total()); } diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 9fe8dfd43..9cd8ea40c 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.finance.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinanceReadModels.Statement; import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; @@ -23,6 +24,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class FinanceExportProviderServiceTest { @@ -87,7 +89,27 @@ class FinanceExportProviderServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("snapshotToken"); } + @Test + void capsFileBatchSizeToFinanceQueryPageLimitForAccountAndLedgerExports() { + when(accountService.list(1, 200, null, null, 10001L)) + .thenReturn(PageEnvelope.success(List.of(), 1, 200, 0)); + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null)) + .thenReturn(PageEnvelope.success(List.of(), 1, 200, 0)); + + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("customerId", "10001"), null, null, 500)); + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, + request(Map.of("customerId", "10001"), null, null, 500)); + + verify(accountService).list(1, 200, null, null, 10001L); + verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null); + } + private ExportBatchRequest request(Map parameters, String cursor, String snapshot) { - return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, 100); + return request(parameters, cursor, snapshot, 100); + } + + private ExportBatchRequest request(Map parameters, String cursor, String snapshot, int limit) { + return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, limit); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 22ea7eac0..7d197ce2f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -155,6 +155,40 @@ public class FeeItemRepository { .set(InboundFeeDetailEntity::getUpdatedAt, now)); } + public int supersedeFreight(long tenantId, long inboundId, long retainedSourceFeeId, + long operatorId, LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING") + .in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED")) + .ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId) + .set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getSettlementIncluded, false) + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); + } + + public int publishCalculatedFreight(long tenantId, long sourceFeeId, long operatorId, + String operatorName, LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING") + .eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId) + .eq(InboundFeeDetailEntity::getPublicationStatus, "DRAFT") + .set(InboundFeeDetailEntity::getReviewStatus, "APPROVED") + .set(InboundFeeDetailEntity::getReviewerId, operatorId) + .set(InboundFeeDetailEntity::getReviewerName, operatorName) + .set(InboundFeeDetailEntity::getReviewedAt, now) + .set(InboundFeeDetailEntity::getPublicationStatus, "PUBLISHED") + .set(InboundFeeDetailEntity::getPublishedBy, operatorId) + .set(InboundFeeDetailEntity::getPublishedByName, operatorName) + .set(InboundFeeDetailEntity::getPublishedAt, now) + .set(InboundFeeDetailEntity::getSettlementIncluded, true) + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); + } + public void insertDetail(InboundFeeDetailEntity detail) { feeDetails.insert(detail); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index da36408e2..5fbc5760f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -81,6 +81,7 @@ public class FbaPricingService { estimate.chargeUnit(), chargeable) != 1) { throw conflict("入库单状态已变化,费用试算未保存"); } + feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); complete(token, fee.getId(), now); String nextStatus = isCompletedInbound(inbound.getProcessStatus()) ? inbound.getProcessStatus() : "FEE_CONFIRMING"; @@ -215,6 +216,9 @@ public class FbaPricingService { feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, tenantId, inboundId, adjusted.getId(), nextVersion, now); + } else { + feeDetails.materializeCalculatedFreight( + tenantId, inboundId, adjusted.getId(), operatorId, now); } complete(token, adjusted.getId(), now); return response(tenantId, adjusted.getId(), inbound.getProcessStatus()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index e0a026355..3123b3ae6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -48,7 +48,7 @@ public class InboundFeeDetailService { long operatorId, String operatorName, LocalDateTime publishedAt) { InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); - if (existing != null) return existing; + if (existing != null && "PUBLISHED".equals(existing.getPublicationStatus())) return existing; InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId); if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) { @@ -58,6 +58,14 @@ public class InboundFeeDetailService { throw conflict("只有已发送状态的计费快照才能生成客户运费明细"); } LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt; + if (existing != null) { + repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now); + if (repository.publishCalculatedFreight( + tenantId, feeId, operatorId, operatorName, now) != 1) { + throw conflict("初算费用明细状态已变化,请刷新后重试"); + } + return repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); + } FeeItemEntity feeItem = requiredFreightItem(); InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId); repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now); @@ -121,6 +129,62 @@ public class InboundFeeDetailService { } } + /** 初算成功后生成后台可见的草稿运费明细,不发布 Finance。 */ + @Transactional + public InboundFeeDetailEntity materializeCalculatedFreight(long tenantId, long inboundId, long feeId, + long operatorId, LocalDateTime calculatedAt) { + InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); + if (existing != null) return existing; + InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); + InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId); + if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) { + throw new BizException(ResultCode.NOT_FOUND, "待展示的入库计费快照不存在"); + } + if (!"CALCULATED".equals(fee.getFeeStatus())) { + throw conflict("只有初算状态的计费快照可生成草稿运费明细"); + } + LocalDateTime now = calculatedAt == null ? LocalDateTime.now() : calculatedAt; + repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now); + InboundFeeDetailEntity detail = freightDetail( + tenantId, inboundId, operatorId, null, now, inbound, fee, requiredFreightItem(), false); + repository.insertDetail(detail); + return detail; + } + + private InboundFeeDetailEntity freightDetail(long tenantId, long inboundId, long operatorId, + String operatorName, LocalDateTime now, + InboundOrderEntity inbound, InboundFeeEntity fee, + FeeItemEntity feeItem, boolean published) { + InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, fee.getId()); + InboundFeeDetailEntity detail = new InboundFeeDetailEntity(); + detail.setTenantId(tenantId); detail.setInboundId(inboundId); detail.setFeeItemId(feeItem.getId()); + detail.setSourceType("FBA_PRICING"); detail.setSourceFeeId(fee.getId()); + detail.setSourceFeeVersion(fee.getFeeVersion()); detail.setSourceFeeNo(fee.getFeeNo()); + detail.setInboundCode(inbound.getInboundCode()); detail.setDirection("RECEIVABLE"); + detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(inbound.getCustomerId()); + detail.setCustomerName(inbound.getCustomerName()); detail.setInvoiceTitle(inbound.getCompanyName()); + detail.setFeeCategorySnapshot(feeItem.getCategory()); detail.setTradeScopeSnapshot(feeItem.getTradeScope()); + detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName()); detail.setChannelCode(inbound.getChannelCode()); + detail.setChannelName(inbound.getChannelName()); detail.setDestinationCode(inbound.getDestinationCode()); + detail.setDestinationName(inbound.getDestinationName()); detail.setPackageCount(inbound.getTotalPackageCount()); + detail.setActualGrossWeightKg(fee.getActualWeightKg()); detail.setActualVolumeCbm(fee.getActualVolumeCbm()); + detail.setVolumeWeightKg(inbound.getVolumeWeightKg()); + detail.setChargeableWeightKg("KG".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableWeightKg()); + detail.setChargeableVolumeCbm("CBM".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableVolumeCbm()); + detail.setChargeableQuantity(fee.getChargeableQuantity()); detail.setChargeUnit(fee.getChargeUnit()); + detail.setUnitPrice(baseItem == null ? effectiveUnitPrice(fee) : baseItem.getUnitPrice()); + detail.setCurrency(fee.getCurrency()); detail.setAmount(fee.getTotalAmount().setScale(2, RoundingMode.HALF_UP)); + detail.setRemark("来源费用单:" + fee.getFeeNo()); + detail.setReviewStatus(published ? "APPROVED" : "PENDING"); + detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT"); + detail.setSettlementIncluded(published); detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId); + if (published) { + detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now); + detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now); + } + return detail; + } + @Transactional public InboundFeeDetailEntity create(long tenantId, long inboundId, long operatorId, SaveRequest request) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 31da1f7ba..aa583b95b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -143,6 +143,7 @@ class FbaPricingServiceTest { assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", new BigDecimal("167.000")); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW); verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); } @@ -366,6 +367,7 @@ class FbaPricingServiceTest { assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); } diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 964d8e5dc..ce2b4c923 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -62,6 +62,7 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) 'platform:finance:settlement-ap:list','platform:finance:customer-account:export', 'platform:finance:customer-arap:export','platform:finance:customer-statement:export', 'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export', + 'platform:download:list', 'platform:firstleg:inbound:measure:view', 'firstleg:inbound:measurement-review','platform:supplier:list', 'platform:supplier:create','platform:supplier:edit','platform:supplier:status', diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index cfedd122b..066cf4a81 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -43,8 +43,22 @@ class AdminPrototypeMenuPermissionSqlContractTest { .doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044") .contains("role_code='PLATFORM_ADMIN'"); + int grantStart = sql.indexOf("role_code='PLATFORM_ADMIN'"); + String platformAdminGrant = sql.substring(grantStart, sql.indexOf(");", grantStart)); + for (String permission : FINANCE_EXPORTS) assertThat(platformAdminGrant).contains(permission); + assertThat(platformAdminGrant).contains("platform:download:list"); + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); - for (String permission : FINANCE_EXPORTS) assertThat(unified).contains(permission); + for (int index = 0; index < FINANCE_EXPORTS.length; index++) { + assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); + } + assertThat(unified) + .contains("'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") + .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") + .contains("Finance 导出和下载中心授权不完整") + .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) + .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } @Test diff --git a/scripts/README.md b/scripts/README.md index ec7368ecd..8b4e5ba46 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -104,7 +104,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令 ### 本地 MySQL 全量验收 -正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `1DBA47099AE501B21C4BEC22A50D065571BA46264C15C6C543CBE6F1CC597463` 和 15215 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 +正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA` 和 15403 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 ```powershell .\scripts\local-db-full-acceptance.ps1 -DryRun diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs index 889ff8803..7c9f832f6 100644 --- a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -3,13 +3,18 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { EXPORT_SPECS, run } from './finance-downloads.mjs' +import { EXPORT_SPECS, REQUIRED_PERMISSIONS, run } from './finance-downloads.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-downloads.mjs'), 'utf8') test('八类 Finance XLSX 契约均有正式表头并在完整链末尾执行', () => { + assert.deepEqual(REQUIRED_PERMISSIONS, [ + 'platform:download:list', 'platform:finance:customer-account:export', + 'platform:finance:customer-arap:export', 'platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export', 'platform:finance:settlement-ap:export', + ]) assert.deepEqual(Object.keys(EXPORT_SPECS), [ 'FINANCE_CUSTOMER_ACCOUNT_LIST', 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', 'FINANCE_FEE_SOURCE_LIST', 'FINANCE_RECONCILIATION_STATEMENT_LIST', 'FINANCE_RECEIVABLE_SETTLEMENT_LIST', @@ -39,11 +44,17 @@ test('未部署时能力探测后记录部署前置,不借历史业务数据', const steps = []; const gaps = [] const context = { marker: 'ADMIN-API-PROBE', createdResources: {}, - apiSuccess(module, name) { steps.push({ module, name, passed: false }); return null }, + apiSuccess(module, name, response) { + const passed = response.http === 200 && String(response.body?.code) === '200' + steps.push({ module, name, passed }); return passed ? response.body.data : null + }, assert(module, name) { steps.push({ module, name, passed: false }) }, addStep(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, - async request() { return { http: 200, body: { code: 'A0403', message: 'not deployed' } } }, + async request(method, path) { + if (path.endsWith('/authorization/permissions')) return { http: 200, body: { code: '200', data: REQUIRED_PERMISSIONS } } + return { http: 200, body: { code: 'A0403', message: 'not deployed' } } + }, } await run(context) assert.equal(context.createdResources.financeExportTasks.marker, context.marker) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index cc6c0f743..a851aa6ce 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -3,6 +3,14 @@ import { inflateRawSync } from 'node:zlib' const MODULE = 'finance-downloads' const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'EXPIRED']) const OPTIONS = { scenario: 'finance-download-center', deploymentBlocker: true } +export const REQUIRED_PERMISSIONS = Object.freeze([ + 'platform:download:list', + 'platform:finance:customer-account:export', + 'platform:finance:customer-arap:export', + 'platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export', + 'platform:finance:settlement-ap:export', +]) export const EXPORT_SPECS = Object.freeze({ FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'], @@ -180,15 +188,26 @@ async function noPermission(context) { export async function run(context) { context.createdResources.financeExportTasks = { marker: context.marker, tasks: {} } + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') + const permissionData = context.apiSuccess(MODULE, 'fresh login 权限快照', permissionsResponse, '', OPTIONS) + const permissions = new Set(Array.isArray(permissionData) ? permissionData : []) + const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !permissions.has(permission)) + context.assert(MODULE, '下载中心与五项 Finance export 权限齐全', permissionsResponse, + missingPermissions.length === 0, `missing=${missingPermissions.join(',') || 'none'}`, OPTIONS) + if (missingPermissions.length > 0) { + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', + `fresh login 权限快照缺失:${missingPermissions.join(',')}`, 'deployment-prerequisite') + return + } const longMissingCustomerId = '9223372036854775806' const probe = await createTask(context, 'FINANCE_CUSTOMER_ACCOUNT_LIST', { customerId: longMissingCustomerId }, 'CAPABILITY-PROBE', EXPORT_SPECS.FINANCE_CUSTOMER_ACCOUNT_LIST) + context.createdResources.financeExportTasks.probe = probe if (!probe || probe.status !== 'SUCCEEDED') { context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', 'Finance/File/权限/配置尚未完整部署,能力探测未成功;部署后重跑完整链', 'deployment-prerequisite') return } - context.createdResources.financeExportTasks.probe = probe context.assert(MODULE, '长 ID 字符串与空结果', null, probe.processedRows === 0, `customerIdType=string, customerId=${longMissingCustomerId}, rows=${probe.processedRows}`, OPTIONS) await noPermission(context) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 553f8830f..1eff76bdd 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -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 = '1DBA47099AE501B21C4BEC22A50D065571BA46264C15C6C543CBE6F1CC597463' -$ExpectedLines = 15215 +$ExpectedSha256 = '00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA' +$ExpectedLines = 15403 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')