This commit is contained in:
2026-08-19 14:02:11 +08:00
590 changed files with 23946 additions and 5168 deletions
+10 -3
View File
@@ -39,19 +39,26 @@
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
## 契约、数据库与验证入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
Vendored
+4 -4
View File
@@ -52,7 +52,7 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
@@ -66,9 +66,9 @@ pipeline {
steps {
script {
String requestedRepository
if (params.ENVIRONMENT == 'test') {
requestedRepository = 'baiyue-firstleg-test'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry'
if (['development', 'test'].contains(params.ENVIRONMENT)) {
requestedRepository = "baiyue-firstleg-${params.ENVIRONMENT}"
echo "${params.ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry"
} else {
requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
+7 -4
View File
@@ -36,7 +36,7 @@ pipeline {
parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签')
@@ -47,6 +47,9 @@ pipeline {
REGISTRY_CREDENTIALS = 'gitea-test-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/application'
}
stages {
@@ -81,7 +84,7 @@ pipeline {
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://')
}
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) {
if (!['development', 'test'].contains(params.ENVIRONMENT) && !imageRepository.contains('/')) {
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
@@ -137,8 +140,8 @@ pipeline {
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
if (params.ENVIRONMENT == 'test') {
echo "测试镜像已保存在当前 Docker 节点:${image}"
if (['development', 'test'].contains(params.ENVIRONMENT)) {
echo "${params.ENVIRONMENT} 镜像已保存在当前 Docker 节点:${image}"
} else {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
@@ -1,8 +1,10 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/
@MapperScan("com.baiyue.audit.mapper")
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
String username,
String appCode,
String clientType,
String clientIp,
String requestId,
String traceId,
String serviceName,
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
@@ -0,0 +1,17 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueAuditApplicationContractTest {
@Test
void importsSharedMybatisPaginationConfiguration() {
Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class);
assertThat(imported).isNotNull();
assertThat(imported.value()).contains(MybatisPlusConfiguration.class);
}
}
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List;
import java.util.Set;
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
verify(service).detail(8L);
}
@Test
void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException {
String expected = "hasAuthority('platform:audit:list')";
assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class,
long.class, long.class, Long.class, Long.class, String.class, String.class, String.class,
Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
}
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData");
}
@Test
void pagePreservesTotalAndPagesForCustomerLogPagination() {
AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null,
null, null, null);
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test
void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L));
+4 -2
View File
@@ -12,7 +12,9 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -22,7 +24,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -48,7 +48,26 @@ class BusinessAuditAspectTest {
assertThat(event.operatorTenantId()).isEqualTo(10001L);
}
@Test
void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable {
AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
assertThat(event.success()).isFalse();
assertThat(event.errorCode()).isEqualTo("IllegalStateException");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.afterData()).isNull();
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, null);
}
private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, new IllegalStateException("validation failed"));
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable {
AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink);
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
when(joinPoint.proceed()).thenReturn("ok");
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
else when(joinPoint.proceed()).thenThrow(failure);
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
try {
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
} catch (Throwable error) {
if (failure == null) throw error;
}
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture());
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
}
@Test
void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024);
String sanitized = sanitizer.sanitize(Map.of(
"profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"),
"contacts", java.util.List.of(Map.of("emailAddress", "user@example.com"))));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******");
assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******");
assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
+5 -4
View File
@@ -14,12 +14,13 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` |
| 契约说明 | `package-info.java` |
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` |
## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -0,0 +1,79 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
List<Order> orders) {
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark,
String palletNo, List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
null, List.of());
}
}
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,42 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
.isEqualTo("仓库备注");
}
@Test
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
}
+1 -1
View File
@@ -18,6 +18,6 @@
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
+5 -3
View File
@@ -9,17 +9,19 @@
| 功能 | 入口 |
|---|---|
| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` |
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
## 修改与验证
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
+1 -1
View File
@@ -22,6 +22,6 @@
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+83
View File
@@ -21,3 +21,86 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+4
View File
@@ -8,3 +8,7 @@ spring:
baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+83
View File
@@ -17,4 +17,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+4
View File
@@ -5,3 +5,7 @@ spring.datasource:
password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
@@ -0,0 +1,116 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 4 ]; then
echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2
exit 2
fi
IMAGE_REPOSITORY="$1"
IMAGE_TAG="$2"
SERVICE_CSV="$3"
ENV_FILE="$4"
SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}"
SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}"
SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}"
REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}"
COMPOSE_FILE="deploy/docker-compose.yml"
if [ ! -f "$SSH_KEY" ]; then
echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2
exit 2
fi
if [ ! -f "$SSH_KNOWN_HOSTS" ]; then
echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2
exit 2
fi
if [ ! -f "$ENV_FILE" ]; then
echo "开发环境变量文件不存在: $ENV_FILE" >&2
exit 2
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Compose 文件不存在: $COMPOSE_FILE" >&2
exit 2
fi
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ')
for service in $SERVICES; do
case "$service" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $service" >&2
exit 2
;;
esac
done
run_ssh() {
ssh -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}"
REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE'
set -eu
remote_dir="$1"
env_tmp="$2"
compose_tmp="$3"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
mv -f "$env_tmp" "$remote_dir/.env"
mv -f "$compose_tmp" "$remote_dir/docker-compose.yml"
REMOTE_PREPARE
for service in $SERVICES; do
image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG"
docker image inspect "$image" >/dev/null
echo "传输开发镜像: $image"
docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load'
done
run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
image_repository="$2"
image_tag="$3"
service_csv="$4"
services=$(printf '%s' "$service_csv" | tr ',' ' ')
cd "$remote_dir"
export DEPLOY_ENV=development
export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag"
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never $services
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
ps $services
REMOTE_DEPLOY
+5
View File
@@ -74,6 +74,11 @@ esac
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
if [ "$DEPLOY_ENV" = "development" ]; then
exec sh deploy/scripts/deploy-development-service.sh \
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
fi
PULL_POLICY_ARGS=""
if [ "$DEPLOY_ENV" = "test" ]; then
PULL_POLICY_ARGS="--pull never"
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -17,6 +17,7 @@
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` |
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` |
| 表结构 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` |
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
@@ -28,5 +29,6 @@
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。
- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。
- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。
- 从父目录运行 `mvn -pl event-bridge-service -am test`。
+3 -1
View File
@@ -10,8 +10,9 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -21,6 +22,7 @@
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 全量验收导航
@@ -20,12 +20,14 @@ public class ExportProviderClient {
@Resource private OrderExportProviderClient order;
@Resource private WarehouseExportProviderClient warehouse;
@Resource private FirstlegExportProviderClient firstleg;
@Resource private FinanceExportProviderClient finance;
@Resource
private MeterRegistry meters;
@PostConstruct
void initializeClients() {
this.clients = Map.of("iam-service", iam, "order-service", order,
"warehouse-service", warehouse, "firstleg-service", firstleg);
"warehouse-service", warehouse, "firstleg-service", firstleg,
"finance-service", finance);
}
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -0,0 +1,7 @@
package com.baiyue.file.client;
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name="finance-service", contextId="financeExportProviderClient",
url="${baiyue.clients.finance.url:}", configuration=ExportProviderFeignConfiguration.class)
interface FinanceExportProviderClient extends ExportProviderFeignClient { }
@@ -11,10 +11,10 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service");
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+:[a-z-]+:[a-z-]+");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
private Map<String, Definition> definitions = Map.of();
@PostConstruct
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -43,9 +44,32 @@ public class InternalLoadingEvidenceController {
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (org.springframework.http.InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -36,7 +36,7 @@ public class PlatformLoadingEvidenceController {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
@@ -51,4 +51,13 @@ public class PlatformLoadingEvidenceController {
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
+86
View File
@@ -35,6 +35,9 @@ spring:
firstlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
financeExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
servlet:
multipart:
max-file-size: 10MB
@@ -53,6 +56,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
integration:
url: ${INTEGRATION_SERVICE_URL:}
finance:
url: ${FINANCE_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
price-book:
@@ -96,6 +101,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -24,6 +24,7 @@ class ExportProviderClientTest {
@MockBean private OrderExportProviderClient order;
@MockBean private WarehouseExportProviderClient warehouse;
@MockBean private FirstlegExportProviderClient firstleg;
@MockBean private FinanceExportProviderClient finance;
@Resource private ExportProviderClient client;
@Configuration(proxyBeanMethods = false)
@@ -44,6 +45,19 @@ class ExportProviderClientTest {
assertSame(response,actual);
verify(order).batch("token","ORDER_LIST",request);
verifyNoInteractions(iam,warehouse,firstleg);
verifyNoInteractions(iam,warehouse,firstleg,finance);
}
@Test void routesFinanceExportsToFinanceService() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("finance-service");
ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500);
var actual=client.batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",definition,request);
assertSame(response,actual);
verify(finance).batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",request);
verifyNoInteractions(iam,order,warehouse,firstleg);
}
}
@@ -12,6 +12,20 @@ class ExportDefinitionRegistryTest {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches")));
assertDoesNotThrow(registry::validate);
}
@Test void acceptsTheControlledFinanceProvider() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches");
finance.setRequiredPermission("platform:finance:customer-statement:export");
registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance));
assertDoesNotThrow(registry::validate);
}
@Test void acceptsTheReceivableStatisticsProvider() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches");
finance.setRequiredPermission("platform:finance:receivable-statistics:export");
registry.setDefinitions(Map.of("FINANCE_RECEIVABLE_STATISTICS", finance));
assertDoesNotThrow(registry::validate);
}
@Test void rejectsSsrfAndPathTypeMismatch() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
@@ -0,0 +1,52 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
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.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalLoadingEvidenceController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalLoadingEvidenceControllerTest {
@MockBean LoadingEvidenceService service;
@Resource InternalLoadingEvidenceController controller;
@Test void downloadsOwnedEvidenceBehindInternalToken() {
when(service.download(81L, 91L, 101L)).thenReturn(
new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 81L, 91L, 101L);
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg");
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
verify(service).download(81L, 91L, 101L);
}
@Test void rejectsInvalidTokenBeforeReadingEvidence() {
assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
verifyNoInteractions(service);
}
@Test void fallsBackForInvalidOrNonImageContentTypes() {
when(service.download(81L, 91L, 101L)).thenReturn(
new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1}));
when(service.download(81L, 91L, 102L)).thenReturn(
new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2}));
assertThat(controller.download("internal-token", 81L, 91L, 101L).getHeaders().getContentType())
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
assertThat(controller.download("internal-token", 81L, 91L, 102L).getHeaders().getContentType())
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
}
}
+13 -2
View File
@@ -2,7 +2,7 @@
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、收款及分配、成本及分摊、账单修订、幂等记录和财务审计状态。
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
- 产品范围先读 `../../docs/财务管理/财务管理产品确认表.md` 与 `财务一期五页产品与接口验收契约.md`,跨服务契约再读 `Firstleg-Finance跨服务契约.md`;专题 README、证据矩阵和测试环境报告只用于状态或验收问题,不作为普通开发首读。
- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
@@ -13,16 +13,22 @@
| 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` |
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
| 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` |
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
| Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` |
| 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` |
| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` |
| 数据库 | `src/main/resources/db/finance.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` |
| 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` |
| 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 |
@@ -32,9 +38,14 @@
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。
## 验证
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -15,20 +16,23 @@ import java.time.LocalDateTime;
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long partnerId, String partnerName,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
String publicationStatus,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
@@ -0,0 +1,24 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "iam-service", contextId = "financeIamCustomerClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamCustomerClient {
@GetMapping("/finance-customers")
ApiResponse<PageResult<Customer>> page(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam int page,
@RequestParam int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) Long customerId);
record Customer(long id, String tenantCode, String tenantName, String status,
String contactName, String contactPhone) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "warehouse-service", contextId = "financeWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
public interface WarehouseSupplierClient {
@GetMapping("/{supplierId}")
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam(required = false) String category);
record SupplierSnapshot(long supplierId, String name, String category, String status) {}
}
@@ -4,8 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -16,9 +19,12 @@ import org.springframework.web.bind.annotation.*;
public class ClientFinanceController {
private final FinanceService service;
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
private final ReconciliationStatementService reconciliationStatementService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService,
ReconciliationStatementService reconciliationStatementService) {
this.service = service;
this.queryService = queryService;
this.reconciliationStatementService = reconciliationStatementService;
}
@GetMapping("/bills")
@@ -45,6 +51,17 @@ public class ClientFinanceController {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@PostMapping("/statements/{statementId}/confirm")
@PreAuthorize("hasAuthority('merchant:finance:statement:confirm')")
@Operation(summary = "客户确认本人已出账对账单")
public ApiResponse<?> confirmStatement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long statementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CustomerConfirmationRequest request) {
return FinanceResponses.success(reconciliationStatementService.confirmForCustomer(
user.tenantId(), user.userId(), statementId, idempotencyKey, request));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
@@ -0,0 +1,51 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.finance.service.FinanceExportProviderService;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Internal export provider consumed only by File Service. */
@RestController
@RequestMapping("/internal/exports/v1")
public class FinanceExportProviderController {
private final FinanceExportProviderService service;
private final String configuredToken;
private byte[] internalToken;
public FinanceExportProviderController(FinanceExportProviderService service,
@Value("${baiyue.security.internal-service-token}") String configuredToken) {
this.service = service;
this.configuredToken = configuredToken;
}
@PostConstruct
void initializeToken() {
byte[] configured = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8);
if (configured.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
internalToken = configured;
}
@PostMapping("/{exportType}/batches")
public ApiResponse<ExportBatchResponse> batch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable String exportType,
@RequestBody ExportBatchRequest request) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
return ApiResponse.success(service.exportBatch(exportType, request));
}
}
@@ -3,8 +3,11 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -24,15 +27,23 @@ import java.time.LocalDate;
@RequestMapping("/api/platform/finance/v1")
public class FinancePhase1OperationController {
private final FinancePhase1OperationService service;
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
private final FinanceCustomerAccountService customerAccountService;
@Autowired
public FinancePhase1OperationController(FinancePhase1OperationService service,
FinanceCustomerAccountService customerAccountService) {
this.service = service;
this.customerAccountService = customerAccountService;
}
@GetMapping("/accounts")
@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,
@RequestParam(required = false) String status) {
return service.listDerivedCustomerAccounts(page, pageSize, keyword, status);
@RequestParam(required = false) String status,
@RequestParam(required = false) Long customerId) {
return customerAccountService.list(page, pageSize, keyword, status, customerId);
}
@GetMapping("/accounts/{customerId}/ledger")
@@ -46,6 +57,22 @@ public class FinancePhase1OperationController {
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
}
@GetMapping("/fee-sources")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public Object listFinanceFeeSources(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String direction,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) Boolean statementGenerated,
@RequestParam(required = false) LocalDate businessDateFrom,
@RequestParam(required = false) LocalDate businessDateTo,
@RequestParam(required = false) String keyword) {
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
statementGenerated, businessDateFrom, businessDateTo, keyword);
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
@@ -61,12 +88,39 @@ public class FinancePhase1OperationController {
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SettlementInclusionRequest request) {
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
@PutMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false));
}
@PostMapping("/fee-sources/{feeSourceId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PostMapping("/fee-sources/{feeSourceId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true));
}
@PostMapping("/fee-sources/{feeSourceId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
}
@@ -74,7 +74,7 @@ public class PlatformFinanceController {
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
return FinanceResponses.success(queryService.reconciliationStatements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@@ -105,22 +105,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@@ -228,7 +212,6 @@ public class PlatformFinanceController {
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
public record VersionRequest(int expectedVersion) {}
public record ReasonRequest(int expectedVersion, String reason) {}
@@ -0,0 +1,42 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.ReceivableStatisticsService;
import com.baiyue.finance.service.FinanceEventException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.YearMonth;
@Tag(name = "应收款统计")
@RestController
@RequestMapping("/api/platform/finance/v1/receivable-statistics")
public class ReceivableStatisticsController {
private final ReceivableStatisticsService service;
public ReceivableStatisticsController(ReceivableStatisticsService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')")
@Operation(summary = "按生成月份和客户汇总CNY正式应收结算单")
public ApiResponse<ReceivableStatisticsResponse> query(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month,
@RequestParam(required = false) Long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize));
} catch (java.time.format.DateTimeParseException exception) {
throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM");
}
}
}
@@ -37,21 +37,33 @@ public final class FinancePhase1Dtos {
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName,
Long supplierId, String supplierName, Long orderId,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId,
int sourceVersion, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long inboundId, String inboundCode, Long containerId,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String settlementReason, String sourceHash,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
String result, String remark) {}
public record ReviewRequest(String action, String reason, int expectedVersion) {}
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {}
public record FeeSourceActionRequest(String reason, int expectedVersion) {}
private static String correlation(String first, String second) {
String value = MDC.get(first);
@@ -69,9 +69,50 @@ public final class FinanceReadModels {
String currency,
String status,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record ReconciliationSummary(
long statementId,
String statementNo,
long customerId,
String customerName,
LocalDate periodStart,
LocalDate periodEnd,
int creditDays,
String currency,
int lineCount,
int sourceCount,
BigDecimal sourceAmount,
BigDecimal adjustmentAmount,
BigDecimal totalReceivable,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
LocalDateTime lastReceiptAt,
boolean settled,
String status,
LocalDateTime submittedAt,
LocalDateTime reviewedAt,
LocalDateTime issuedAt,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
String remark,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
@@ -168,6 +209,7 @@ public final class FinanceReadModels {
String billSnapshot,
String lineSnapshot,
long createdBy,
String createdByName,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
@@ -22,23 +22,28 @@ public final class FinanceSettlementDtos {
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long reconciliationStatementId, String businessPeriod, String currency,
Long reconciliationStatementId, String reconciliationStatementNo,
String businessPeriod, String feeItemSummary, String currency,
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
String status, int version) {}
String status, long createdBy, String createdByName,
LocalDateTime createdAt, LocalDateTime updatedAt,
LocalDateTime lastPaymentAt, int version) {}
public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName, Long supplierId,
String supplierName, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
String supplierName, Long orderId, String orderNo, Long inboundId,
String inboundCode, Long containerId, String containerNo,
LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, boolean settlementIncluded, String sourceStatus,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt,
String remark) {}
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions,
@@ -0,0 +1,19 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
public final class ReceivableStatisticsDtos {
private ReceivableStatisticsDtos() {}
public record ReceivableStatisticsRow(long customerId, String customerName, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding) {}
public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding, long totalCustomers,
int page, int pageSize,
List<ReceivableStatisticsRow> records) {}
}
@@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos {
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
List<StatementAdjustmentLineRequest> adjustmentLines) {}
public record CustomerConfirmationRequest(int expectedVersion) {}
public record CustomerConfirmationResult(long statementId, String confirmationStatus,
long confirmedBy, String confirmedByName,
LocalDateTime confirmedAt, int version) {}
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
String reason) {}
@@ -23,23 +29,29 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt, String remark,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
long createdBy, String createdByName, LocalDateTime createdAt,
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
List<ReconciliationRevision> revisions) {}
public record ReconciliationLine(long statementLineId, long feeSourceId, int sourceVersion,
String sourceHash, Long inboundId, Long containerId,
String sourceHash, String direction, String sourceType,
String sourceNo, Long orderId, Long inboundId, Long containerId,
String inboundCode, String containerNo, LocalDate businessDate,
BigDecimal quantity, BigDecimal unitPrice,
String feeItemName, BigDecimal sourceAmount,
BigDecimal statementAmount, String currency,
String adjustmentReason) {}
public record PaymentHistory(long transactionId, String transactionType,
LocalDate transactionDate, BigDecimal amount,
String method, String referenceNo, long createdBy,
String method, String referenceNo, List<String> voucherFileIds,
long createdBy, String createdByName,
LocalDateTime createdAt, String remark) {}
public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -0,0 +1,124 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.finance.client.IamCustomerClient;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
}
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
validatePage(page, pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase();
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) {
throw new FinanceEventException("FIN-REQ-400", "账户状态无效");
}
ApiResponse<PageResult<IamCustomerClient.Customer>> response;
try {
response = iamCustomerClient.page(internalToken, page, pageSize, blankToNull(keyword), normalizedStatus, customerId);
} catch (RuntimeException error) {
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
}
if (response == null || !response.successful() || response.data() == null) {
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
}
PageResult<IamCustomerClient.Customer> customers = response.data();
Map<Long, Metrics> metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList());
List<AccountSummary> rows = customers.records().stream().map(customer -> {
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(),
customer.status(), value.lastChangedAt());
}).toList();
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
}
private Map<Long, Metrics> loadMetrics(List<Long> customerIds) {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray())
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
Object[] changeArgs = new Object[customerIds.size() * 3];
for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size());
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs)
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
Map<Long, Metrics> frozen = new HashMap<>();
result.forEach((id, value) -> frozen.put(id, value.freeze()));
return frozen;
}
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> row, String field, String valueKey) {
Object id = row.get("customer_id");
if (!(id instanceof Number number)) return;
MutableMetrics target = result.get(number.longValue());
if (target == null) return;
Object value = row.get(valueKey);
if ("lastChangedAt".equals(field)) {
target.lastChangedAt = value instanceof Timestamp timestamp ? timestamp.toLocalDateTime()
: value instanceof LocalDateTime dateTime ? dateTime : null;
} else if (value != null) {
target.set(field, value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()));
}
}
private static void validatePage(int page, int pageSize) {
if (page < 1 || pageSize < 1 || pageSize > 200) throw new FinanceEventException("FIN-REQ-400", "分页参数无效");
}
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static final class MutableMetrics {
private BigDecimal outstandingReceivable = BigDecimal.ZERO;
private BigDecimal pendingBilling = BigDecimal.ZERO;
private BigDecimal receivedInPeriod = BigDecimal.ZERO;
private BigDecimal outstandingPayable = BigDecimal.ZERO;
private LocalDateTime lastChangedAt;
void set(String field, BigDecimal value) {
switch (field) {
case "outstandingReceivable" -> outstandingReceivable = value;
case "pendingBilling" -> pendingBilling = value;
case "receivedInPeriod" -> receivedInPeriod = value;
case "outstandingPayable" -> outstandingPayable = value;
default -> { }
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
LocalDateTime lastChangedAt) {
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
}
}
@@ -0,0 +1,55 @@
package com.baiyue.finance.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
@Autowired
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -0,0 +1,508 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/** Provides versioned, read-only Finance export batches to File Service. */
@Service
public class FinanceExportProviderService {
public static final String CUSTOMER_ACCOUNT_LIST = "FINANCE_CUSTOMER_ACCOUNT_LIST";
public static final String CUSTOMER_ACCOUNT_LEDGER = "FINANCE_CUSTOMER_ACCOUNT_LEDGER";
public static final String FEE_SOURCE_LIST = "FINANCE_FEE_SOURCE_LIST";
public static final String RECONCILIATION_STATEMENT_LIST = "FINANCE_RECONCILIATION_STATEMENT_LIST";
public static final String RECEIVABLE_SETTLEMENT_LIST = "FINANCE_RECEIVABLE_SETTLEMENT_LIST";
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS";
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<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL,
RECEIVABLE_STATISTICS);
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
column("businessType", "业务类型", ExportColumnType.STRING, 18), column("documentNo", "关联单据", ExportColumnType.STRING, 24),
column("receivableChange", "应收变动(CNY)", ExportColumnType.DECIMAL, 18), column("payableChange", "应付变动(CNY)", ExportColumnType.DECIMAL, 18),
column("outstandingReceivableAfter", "变动后未收(CNY)", ExportColumnType.DECIMAL, 20), column("operatorName", "操作人", ExportColumnType.STRING, 16),
column("remark", "备注", ExportColumnType.STRING, 28));
private static final List<ExportColumn> FEE_COLUMNS = List.of(
column("customerName", "客户", ExportColumnType.STRING, 28), column("direction", "收付方向", ExportColumnType.STRING, 14),
column("sourceType", "来源类型", ExportColumnType.STRING, 18), column("sourceFeeId", "来源单据ID", ExportColumnType.STRING, 22),
column("sourceVersion", "来源版本", ExportColumnType.INTEGER, 12), column("inboundCode", "入仓号", ExportColumnType.STRING, 20),
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14),
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("periodStart", "结算起始日", ExportColumnType.DATE, 14), column("periodEnd", "结算结束日", ExportColumnType.DATE, 14),
column("creditDays", "账期(天)", ExportColumnType.INTEGER, 12), column("currency", "货币", ExportColumnType.STRING, 10),
column("totalReceivable", "总应收金额(CNY)", ExportColumnType.DECIMAL, 20), column("receivedAmount", "已收金额(CNY)", ExportColumnType.DECIMAL, 18),
column("outstandingAmount", "未收金额(CNY)", ExportColumnType.DECIMAL, 18), column("settled", "是否结清", ExportColumnType.BOOLEAN, 12),
column("status", "审核状态", ExportColumnType.STRING, 14), column("remark", "备注", ExportColumnType.STRING, 28),
column("createdByName", "创建人", ExportColumnType.STRING, 16), column("createdAt", "创建时间", ExportColumnType.DATETIME, 21),
column("updatedByName", "更新人", ExportColumnType.STRING, 16), column("updatedAt", "更新时间", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> SETTLEMENT_LIST_COLUMNS = List.of(
column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("supplierName", "供应商", ExportColumnType.STRING, 24), column("currency", "货币", ExportColumnType.STRING, 10),
column("totalAmount", "总金额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("businessPeriod", "业务期间", ExportColumnType.STRING, 18),
column("generatedDate", "生成日期", ExportColumnType.DATE, 14), column("dueDate", "到期日期", ExportColumnType.DATE, 14),
column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> SETTLEMENT_DETAIL_COLUMNS = List.of(
column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("supplierName", "供应商", ExportColumnType.STRING, 24), column("feeItemName", "费用项目", ExportColumnType.STRING, 22),
column("inboundCode", "入仓号", ExportColumnType.STRING, 20), column("containerNo", "柜次号", ExportColumnType.STRING, 20),
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> RECEIVABLE_STATISTICS_COLUMNS = List.of(
column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14),
column("customerName", "应收单位", ExportColumnType.STRING, 28),
column("currency", "币种", ExportColumnType.STRING, 10),
column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18),
column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18));
private final FinanceCustomerAccountService accountService;
private final FinancePhase1OperationService operationService;
private final FinanceQueryService queryService;
private final ReconciliationStatementService reconciliationService;
private final FinanceSettlementService settlementService;
private final ReceivableStatisticsService receivableStatisticsService;
private final ObjectMapper objectMapper;
private final String internalServiceToken;
private byte[] snapshotSigningKey;
public FinanceExportProviderService(FinanceCustomerAccountService accountService,
FinancePhase1OperationService operationService,
FinanceQueryService queryService,
ReconciliationStatementService reconciliationService,
FinanceSettlementService settlementService,
ReceivableStatisticsService receivableStatisticsService,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
this.accountService = accountService;
this.operationService = operationService;
this.queryService = queryService;
this.reconciliationService = reconciliationService;
this.settlementService = settlementService;
this.receivableStatisticsService = receivableStatisticsService;
this.objectMapper = objectMapper;
this.internalServiceToken = internalServiceToken;
}
@PostConstruct
void initializeSigningKey() {
snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8);
}
public ExportBatchResponse exportBatch(String exportType, ExportBatchRequest request) {
String type = normalizeType(exportType);
ValidatedRequest validated = validate(type, request);
boolean firstBatch = validated.snapshotToken() == null;
String snapshot = firstBatch ? signSnapshot(type, validated) : verifySnapshot(type, validated);
BatchRows batch = rows(type, validated);
String nextCursor = String.valueOf(validated.offset() + batch.rows().size());
return new ExportBatchResponse(SCHEMA_VERSION, schemaHash(columns(type)), snapshot,
firstBatch ? columns(type) : List.of(), batch.rows(), nextCursor,
validated.offset() + batch.rows().size() < batch.totalRows(), firstBatch ? batch.totalRows() : null);
}
private BatchRows rows(String type, ValidatedRequest request) {
int offset = request.offset();
int limit = request.source().limit();
Map<String, Object> p = request.parameters();
if (CUSTOMER_ACCOUNT_LIST.equals(type)) {
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 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());
}
List<Map<String, Object>> all = switch (type) {
case FEE_SOURCE_LIST -> feeRows(p);
case RECONCILIATION_STATEMENT_LIST -> statementRows(p);
case RECEIVABLE_SETTLEMENT_LIST -> settlementRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p);
default -> throw invalid("Unsupported Finance export type");
};
int from = Math.min(offset, all.size());
int to = Math.min(from + limit, all.size());
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
}
private List<Map<String, Object>> receivableStatisticsRows(Map<String, Object> parameters) {
YearMonth month = statisticsMonth(parameters);
Long customerId = longValue(parameters, "customerId", false);
return receivableStatisticsService.exportRows(month, customerId).stream()
.map(value -> receivableStatisticsRow(month, value)).toList();
}
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String direction = text(p, "direction");
String status = text(p, "status");
String reviewStatus = text(p, "reviewStatus");
Boolean generated = bool(p, "statementGenerated");
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
String keyword = lower(text(p, "keyword"));
List<Map<String, Object>> rows = new ArrayList<>();
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
FeeSourceDetail detail = detail(item);
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
String actualReview = detail == null ? null : detail.reviewStatus();
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
LocalDate businessDate = detail == null || detail.businessDate() == null
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
if (generated != null && !generated.equals(actualGenerated)) continue;
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()),
detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo())));
if (keyword != null && !searchable.contains(keyword)) continue;
rows.add(feeRow(item, detail, businessDate));
}
return rows;
}
private FeeSourceDetail detail(FeePoolItem item) {
if ("MANUAL_COST".equals(item.sourceType())) return null;
String[] parts = item.poolItemId().split(":", -1);
if (parts.length != 2) return null;
try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); }
catch (RuntimeException ignored) { return null; }
}
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String status = text(p, "status"), billNo = lower(text(p, "billNo")), containerNo = lower(text(p, "containerNo"));
LocalDate periodStartFrom = date(p, "periodStartFrom"), periodStartTo = date(p, "periodStartTo");
LocalDate periodEndFrom = date(p, "periodEndFrom"), periodEndTo = date(p, "periodEndTo");
LocalDate lastPaymentFrom = date(p, "lastPaymentFrom"), lastPaymentTo = date(p, "lastPaymentTo");
LocalDate createdFrom = date(p, "createdFrom"), createdTo = date(p, "createdTo");
Boolean settled = bool(p, "settled");
List<Map<String, Object>> rows = new ArrayList<>();
queryService.statements(customerId, false).forEach(summary -> {
ReconciliationDetail detail = reconciliationService.detailForPlatform(summary.id());
LocalDate lastPayment = detail.paymentHistory().stream().map(row -> row.transactionDate()).max(LocalDate::compareTo).orElse(null);
boolean containerMatches = containerNo == null || detail.lines().stream()
.map(line -> feeSourceContainerNo(line.feeSourceId()))
.anyMatch(value -> value != null && value.toLowerCase(Locale.ROOT).contains(containerNo));
if (status != null && !status.equalsIgnoreCase(detail.status())) return;
if (billNo != null && !detail.statementNo().toLowerCase(Locale.ROOT).contains(billNo)) return;
if (!containerMatches || (settled != null && settled != detail.settled())) return;
if (!between(detail.periodStart(), periodStartFrom, periodStartTo) || !between(detail.periodEnd(), periodEndFrom, periodEndTo)) return;
if ((lastPaymentFrom != null || lastPaymentTo != null) && !between(lastPayment, lastPaymentFrom, lastPaymentTo)) return;
if (!between(detail.createdAt().toLocalDate(), createdFrom, createdTo)) return;
rows.add(statementRow(detail));
});
return rows;
}
private String feeSourceContainerNo(long feeSourceId) {
try {
return operationService.getFinanceFeeSource(feeSourceId).containerNo();
} catch (RuntimeException ignored) {
return null;
}
}
private List<Map<String, Object>> settlementRows(String direction, Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String status = text(p, "status"), no = lower(text(p, "settlementNo")), supplier = lower(text(p, "supplierName"));
return settlementService.listSettlements(direction, customerId).stream()
.filter(row -> status == null || status.equalsIgnoreCase(row.status()))
.filter(row -> no == null || row.settlementNo().toLowerCase(Locale.ROOT).contains(no))
.filter(row -> supplier == null || lower(row.supplierName()) != null && lower(row.supplierName()).contains(supplier))
.map(this::settlementRow).toList();
}
private List<Map<String, Object>> settlementDetailRows(String direction, Map<String, Object> p) {
SettlementDetail detail = settlementService.getSettlement(longValue(p, "settlementId", true));
if (!direction.equals(detail.summary().direction())) throw invalid("结算单方向与导出类型不匹配");
if (detail.lines().isEmpty()) return List.of(settlementDetailRow(detail.summary(), null));
return detail.lines().stream().map(line -> settlementDetailRow(detail.summary(), line)).toList();
}
private ValidatedRequest validate(String type, ExportBatchRequest request) {
if (request == null) throw invalid("Export batch request is required");
positive(request.taskId(), "taskId"); positive(request.requestedBy(), "requestedBy");
if (!"0".equals(request.tenantId()) || !"PLATFORM".equals(request.appCode())) throw invalid("Finance export only supports PLATFORM tenant 0");
if (request.dispatchVersion() <= 0 || request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) throw invalid("Finance export batch limit is invalid");
Map<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
Set<String> allowed = allowedParameters(type);
if (!allowed.containsAll(parameters.keySet())) throw invalid(type + " contains unsupported parameters");
validateParameters(type, parameters);
String snapshot = normalize(request.snapshotToken());
if (snapshot != null && snapshot.length() > MAX_SNAPSHOT_TOKEN_LENGTH) throw invalid("snapshotToken is too long");
int offset = integerCursor(request.cursor());
if (snapshot == null && offset != 0) throw invalid("cursor requires a snapshotToken");
return new ValidatedRequest(request, Map.copyOf(parameters), offset, snapshot);
}
private void validateParameters(String type, Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
if (customerId != null && customerId <= 0) throw invalid("customerId must be positive");
String status = text(p, "status");
if (CUSTOMER_ACCOUNT_LIST.equals(type) && status != null && !Set.of("ACTIVE", "DISABLED").contains(status)) throw invalid("account status is invalid");
if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) {
longValue(p, "customerId", true);
String businessType = text(p, "businessType");
if (businessType != null && !LEDGER_TYPES.contains(businessType)) throw invalid("ledger businessType is invalid");
range(p, "startDate", "endDate");
}
if (FEE_SOURCE_LIST.equals(type)) {
String direction = text(p, "direction");
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
String review = text(p, "reviewStatus");
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
}
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
bool(p, "settled"); range(p, "periodStartFrom", "periodStartTo"); range(p, "periodEndFrom", "periodEndTo");
range(p, "lastPaymentFrom", "lastPaymentTo"); range(p, "createdFrom", "createdTo");
max(text(p, "billNo"), 128, "billNo"); max(text(p, "containerNo"), 128, "containerNo");
}
if (type.contains("SETTLEMENT")) {
if (type.endsWith("_DETAIL")) longValue(p, "settlementId", true);
else {
if (status != null && !SETTLEMENT_STATUSES.contains(status)) throw invalid("settlement status is invalid");
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
}
}
if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p);
}
private String signSnapshot(String type, ValidatedRequest request) {
return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(snapshotSubject(type, request)));
}
private String verifySnapshot(String type, ValidatedRequest request) {
String expected = signSnapshot(type, request);
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), request.snapshotToken().getBytes(StandardCharsets.UTF_8))) {
throw invalid("snapshotToken does not match this export task");
}
return request.snapshotToken();
}
private String snapshotSubject(String type, ValidatedRequest request) {
try {
ExportBatchRequest source = request.source();
return String.join("\n", type, source.taskId(), source.tenantId(), source.appCode(), source.requestedBy(),
String.valueOf(source.dispatchVersion()), objectMapper.writeValueAsString(new TreeMap<>(request.parameters())));
} catch (Exception exception) { throw invalid("Finance export parameters cannot be serialized"); }
}
private byte[] hmac(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256"));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
}
private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
}
private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
}
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(),
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
}
private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
}
private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status());
}
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
"currency", value.currency(), "totalReceivable", value.totalReceivable(),
"totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding());
}
private static List<ExportColumn> columns(String type) {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> ACCOUNT_COLUMNS;
case CUSTOMER_ACCOUNT_LEDGER -> LEDGER_COLUMNS;
case FEE_SOURCE_LIST -> FEE_COLUMNS;
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS;
default -> throw invalid("Unsupported Finance export type");
};
}
private static Set<String> allowedParameters(String type) {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
case RECEIVABLE_STATISTICS -> Set.of("month", "customerId");
default -> throw invalid("Unsupported Finance export type");
};
}
private static String normalizeType(String value) {
String type = normalize(value);
if (type == null || !TYPES.contains(type.toUpperCase(Locale.ROOT))) throw invalid("Unsupported Finance export type");
return type.toUpperCase(Locale.ROOT);
}
private static ExportColumn column(String key, String title, ExportColumnType type, int width) { return new ExportColumn(key, title, type, width); }
private static String schemaHash(List<ExportColumn> columns) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (ExportColumn column : columns) digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type() + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8));
return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest());
} catch (GeneralSecurityException exception) { throw new ExceptionInInitializerError(exception); }
}
private static Map<String, Object> row(Object... values) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 0; i < values.length; i += 2) row.put(String.valueOf(values[i]), values[i + 1]);
return row;
}
private static int integerCursor(String value) {
String normalized = normalize(value);
if (normalized == null) return 0;
try { int cursor = Integer.parseInt(normalized); if (cursor < 0) throw invalid("cursor must not be negative"); return cursor; }
catch (NumberFormatException exception) { throw invalid("cursor must be a numeric offset"); }
}
private static Long longValue(Map<String, Object> values, String key, boolean required) {
Object value = values.get(key);
if (value == null || value.toString().isBlank()) { if (required) throw invalid(key + " is required"); return null; }
if (!(value instanceof String)) throw invalid(key + " must be a string ID");
try { long result = Long.parseLong(value.toString()); if (result <= 0) throw invalid(key + " must be positive"); return result; }
catch (NumberFormatException exception) { throw invalid(key + " must be a positive ID"); }
}
private static String text(Map<String, Object> values, String key) {
Object value = values.get(key);
if (value == null) return null;
if (!(value instanceof String text)) throw invalid(key + " must be a string");
return normalize(text);
}
private static Boolean bool(Map<String, Object> values, String key) {
Object value = values.get(key);
if (value == null) return null;
if (!(value instanceof Boolean result)) throw invalid(key + " must be a boolean");
return result;
}
private static LocalDate date(Map<String, Object> values, String key) {
String value = text(values, key);
if (value == null) return null;
try { return LocalDate.parse(value); }
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
}
private static YearMonth statisticsMonth(Map<String, Object> values) {
String value = text(values, "month");
if (value == null) throw invalid("month is required");
try { return YearMonth.parse(value); }
catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); }
}
private static void range(Map<String, Object> values, String fromKey, String toKey) {
LocalDate from = date(values, fromKey), to = date(values, toKey);
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
}
private static boolean between(LocalDate value, LocalDate from, LocalDate to) {
if (value == null) return from == null && to == null;
return (from == null || !value.isBefore(from)) && (to == null || !value.isAfter(to));
}
private static void positive(String value, String field) {
try { if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID"); }
catch (NumberFormatException exception) { throw invalid(field + " must be a positive ID"); }
}
private static void max(String value, int max, String field) { if (value != null && value.length() > max) throw invalid(field + " is too long"); }
private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); }
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String value(Object value) { return value == null ? "" : value.toString(); }
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
private record ValidatedRequest(ExportBatchRequest source, Map<String, Object> parameters, int offset, String snapshotToken) {}
private record BatchRows(List<Map<String, Object>> rows, long totalRows) {}
}
@@ -28,6 +28,7 @@ public class FinanceFeeSourceEventService {
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -93,8 +94,10 @@ public class FinanceFeeSourceEventService {
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"),
positive(payload.feeId(), "feeId"), payload.feeVersion())
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"),
positive(payload.feeId(), "feeId"), payload.feeVersion());
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
@@ -119,13 +122,26 @@ public class FinanceFeeSourceEventService {
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) {
if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
@@ -0,0 +1,26 @@
package com.baiyue.finance.service;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
final class FinanceOperatorSnapshot {
private FinanceOperatorSnapshot() { }
static String currentName(long operatorId) {
if (operatorId == 0) return "系统";
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.userId() != null && user.userId() == operatorId
&& StringUtils.hasText(user.username())) {
return user.username().trim();
}
return null;
}
static String requiredName(long operatorId) {
String name = currentName(operatorId);
return name == null ? "操作人 " + operatorId : name;
}
}
@@ -2,11 +2,13 @@ package com.baiyue.finance.service;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
@@ -40,12 +42,12 @@ public class FinancePhase1OperationService {
this.objectMapper = objectMapper;
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) {
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
page = page(page); pageSize = pageSize(pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT);
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效");
String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus};
String filter = " WHERE (? IS NULL OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
Object[] filterArgs = {blankToNull(customerName), blankToNull(customerName), normalizedStatus, normalizedStatus};
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
@@ -66,9 +68,9 @@ public class FinancePhase1OperationService {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
List<RawLedger> rows = new ArrayList<>();
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO;
List<LedgerEntry> projected = new ArrayList<>();
@@ -76,7 +78,7 @@ public class FinancePhase1OperationService {
BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange);
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, "operator-" + row.operatorId(), row.remark());
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
@@ -86,6 +88,50 @@ public class FinancePhase1OperationService {
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
}
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus,
Boolean statementGenerated, LocalDate businessDateFrom,
LocalDate businessDateTo, String keyword) {
page = page(page); pageSize = pageSize(pageSize);
if (customerId != null) customer(customerId);
if (businessDateFrom != null && businessDateTo != null && businessDateFrom.isAfter(businessDateTo)) {
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
}
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
List<Object> args = new ArrayList<>();
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
String normalizedKeyword = blankToNull(keyword);
if (normalizedKeyword != null) {
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
@@ -101,7 +147,7 @@ public class FinancePhase1OperationService {
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
if (replay != null) return getFinanceFeeSource(replay);
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'",
review, request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
@@ -111,19 +157,68 @@ public class FinancePhase1OperationService {
}
@Transactional
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceEditRequest request, boolean adjustment) {
if (request == null || request.expectedVersion() < 0 || request.amount() == null
|| request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key,
adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay);
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
request.included(), request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
}
int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
request.amount(), feeSourceId, request.expectedVersion(), requiredStatus);
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@Transactional
public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT",
"PENDING", List.of("DRAFT", "REJECTED"), false);
}
@Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request, String operation,
String target, List<String> allowedReviews,
boolean voidSource) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, operation, key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
feeSourceId, request.expectedVersion(), before.reviewStatus())
: jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
target, feeSourceId, request.expectedVersion(), before.reviewStatus());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@@ -136,9 +231,27 @@ public class FinancePhase1OperationService {
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceDetail(id, rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), null, null,
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0
? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, unitPrice, amount, rs.getString("currency_code"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
private List<OperationLog> logs(long customerId, long id) {
@@ -166,23 +279,33 @@ public class FinancePhase1OperationService {
private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId,
FinanceOperatorSnapshot.requiredName(operatorId), MDC.get("requestId"), MDC.get("traceId"));
}
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("remark"));
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable,
rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark"));
}
private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType,
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, String remark) {}
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId,
String operatorName, String remark) {}
private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; }
private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; }
private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); }
private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); }
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String normalized(String value, List<String> allowed, String message) {
String normalized = blankToNull(value);
if (normalized == null) return null;
normalized = normalized.toUpperCase(java.util.Locale.ROOT);
if (!allowed.contains(normalized)) throw error("FIN-REQ-400", message);
return normalized;
}
private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; }
private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); }
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
@@ -190,6 +313,9 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
@@ -15,6 +15,7 @@ import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.ArrayList;
@@ -70,7 +72,7 @@ public class FinanceQueryService {
"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 f.review_status='APPROVED' AND f.settlement_included=1 " +
"AND f.review_status='APPROVED' AND f.amount>0 " +
"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')," +
@@ -124,24 +126,61 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), args);
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"COALESCE(p.credit_days,30) credit_days," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
"COALESCE(l.line_count,0) line_count,COALESCE(l.source_count,0) source_count,COALESCE(l.source_amount,0) source_amount," +
"b.total_amount-COALESCE(l.source_amount,0) adjustment_amount,r.last_receipt_at," +
"a.submitted_at,a.reviewed_at " +
"FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id " +
"LEFT JOIN fin_audit_log c ON c.tenant_id=b.tenant_id AND c.resource_type='RECONCILIATION_STATEMENT' AND c.resource_id=b.id AND c.action='RECONCILIATION_CREATED' AND c.id=(SELECT MIN(c2.id) FROM fin_audit_log c2 WHERE c2.tenant_id=b.tenant_id AND c2.resource_type='RECONCILIATION_STATEMENT' AND c2.resource_id=b.id AND c2.action='RECONCILIATION_CREATED') " +
"LEFT JOIN (SELECT bl.tenant_id,bl.bill_id,COUNT(*) line_count,COUNT(DISTINCT bl.source_id) source_count,SUM(f.amount) source_amount FROM fin_bill_line bl JOIN fin_fee_source f ON f.id=bl.source_id AND f.customer_id=bl.tenant_id GROUP BY bl.tenant_id,bl.bill_id) l ON l.tenant_id=b.tenant_id AND l.bill_id=b.id " +
"LEFT JOIN (SELECT rb.tenant_id,rb.bill_id,MAX(r.received_at) last_receipt_at FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id AND r.status='CONFIRMED' GROUP BY rb.tenant_id,rb.bill_id) r ON r.tenant_id=b.tenant_id AND r.bill_id=b.id " +
"LEFT JOIN (SELECT tenant_id,resource_id,MAX(CASE WHEN action='RECONCILIATION_SUBMIT' THEN created_at END) submitted_at,MAX(CASE WHEN action IN ('RECONCILIATION_APPROVE','RECONCILIATION_REJECT') THEN created_at END) reviewed_at FROM fin_audit_log WHERE resource_type='RECONCILIATION_STATEMENT' GROUP BY tenant_id,resource_id) a ON a.tenant_id=b.tenant_id AND a.resource_id=b.id WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary(
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"),
date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"),
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getString("remark"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), customerId, billId);
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
@@ -158,9 +197,10 @@ public class FinanceQueryService {
public List<BillRevision> billRevisions(long customerId, long billId) {
statement(customerId, billId, false);
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
(rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"),
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"),
rs.getString("created_by_name"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(Long customerId, boolean clientVisibleOnly) {
@@ -246,6 +286,11 @@ public class FinanceQueryService {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static LocalDate date(ResultSet rs, String column) throws SQLException {
java.sql.Date date = rs.getDate(column);
return date == null ? null : date.toLocalDate();
}
private static void requireCustomer(long customerId) {
if (customerId <= 0) throw bad("客户范围无效");
}
@@ -25,11 +25,14 @@ public class FinanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
}
public Map<String, Object> customerTerms(long tenantId) {
@@ -74,14 +77,7 @@ public class FinanceService {
requireCustomer(customerId);
Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId);
if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户");
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
}
@Transactional
public Map<String, Object> issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) {
Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (tenantId == null) throw bad("账单不存在");
return issueBill(tenantId, operatorId, billId, expectedVersion, idempotencyKey);
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
}
public List<Map<String, Object>> listReceipts(Long tenantId) {
@@ -109,47 +105,6 @@ public class FinanceService {
return customerTerms(tenantId);
}
@Transactional
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request);
if (replay != null) return bill(tenantId, replay);
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); });
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() < 0) throw bad("账单金额不能为负数");
Object credit = customerTerms(tenantId).get("credit_days");
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
int days = credit == null ? 30 : ((Number) credit).intValue();
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setLong(12, operatorId); return ps;
}, holder);
long billId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId);
for (BillLine line : request.lines()) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
}
complete(tenantId, operatorId, "BILL_CREATE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
}
@Transactional
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
if (replay != null) return bill(tenantId, replay);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
LocalDateTime.now(), operatorId, operatorId, billId, tenantId, expectedVersion);
if (updated != 1) throw bad("账单不存在或已出账");
complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
}
@Transactional
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
@@ -175,15 +130,15 @@ public class FinanceService {
}
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setLong(11, operatorId); return ps;
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setString(11, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(12, operatorId); return ps;
}, holder);
long receiptId = holder.getKey().longValue();
jdbc.update("UPDATE fin_receipt SET receipt_no=? WHERE id=?", stableNo("RCPT", receiptId), receiptId);
for (var entry : byBill.entrySet()) {
BigDecimal applied = entry.getValue();
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, entry.getKey(), tenantId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, FinanceOperatorSnapshot.currentName(operatorId), entry.getKey(), tenantId);
Integer settlementCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
Integer.class, tenantId, entry.getKey());
@@ -219,14 +174,22 @@ public class FinanceService {
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId);
if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
String supplierName = null;
if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效");
supplierName = supplierValidator.requireActive(request.supplierId(), null).name();
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
validateVoucherFiles(tenantId, request.voucherFileIds());
if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次");
if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积");
if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId());
validateContainerOrders(tenantId, request.containerId(), containerOrders);
String supplierNameSnapshot = supplierName;
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setString(15, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(16, operatorId); return ps; }, holder);
long costId = holder.getKey().longValue();
jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId);
if ("CONTAINER_SHARED".equals(request.costType())) {
@@ -265,7 +228,8 @@ public class FinanceService {
if (allocation.roundingAdjustment()) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot) VALUES(?,?,?,?,?,?,?,?,?)",
tenantId, "FIN_COST_ALLOCATION", costId, "ROUNDING_ADJUSTMENT", "尾差归集到最后一票", null,
json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)), operatorId, "operator-" + operatorId);
json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)),
operatorId, FinanceOperatorSnapshot.requiredName(operatorId));
}
}
}
@@ -334,8 +298,6 @@ public class FinanceService {
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
@@ -359,27 +321,25 @@ public class FinanceService {
Object credit = customerTerms(tenantId).get("credit_days");
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
LocalDate dueDate = effectiveDate.plusDays(credit == null ? 30 : ((Number) credit).intValue());
int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,billId,tenantId,expectedVersion);
int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion);
if (changed != 1) throw bad("仅草稿账单可修改且版本必须匹配");
jdbc.update("UPDATE fin_bill SET total_amount=?, outstanding_amount=? WHERE id=? AND tenant_id=?", total, total, billId, tenantId);
complete(tenantId, operatorId, "BILL_UPDATE", key, billId);
return bill(tenantId,billId);
}
@Transactional
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
@Transactional
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; }
private void snapshot(long tenantId,long billId,int version,String reason,Map<String,Object> bill,long operatorId){
var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId,FinanceOperatorSnapshot.currentName(operatorId));
}
private BigDecimal lineTotal(List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set<String> sources = new java.util.HashSet<>(); return lines.stream().map(l -> { if (l.sourceFeeId() <= 0 || l.sourceFeeVersion() <= 0) throw bad("费用来源版本无效"); if (!sources.add(l.sourceFeeId()+":"+l.sourceFeeVersion())) throw bad("重复费用来源不允许合并"); if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); }
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {}
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
public record ReceiptAllocation(long billId, BigDecimal amount) {}
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
@@ -16,6 +16,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -43,14 +44,42 @@ import java.util.UUID;
public class FinanceSettlementService {
private static final String CNY = "CNY";
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final String SETTLEMENT_SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -82,7 +111,7 @@ public class FinanceSettlementService {
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE",
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
generatedDate, date(bill, "due_date"), operatorId);
@@ -111,13 +140,11 @@ public class FinanceSettlementService {
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,source_snapshot," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerId'))) AS UNSIGNED) supplier_id," +
"COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerName'))) supplier_name " +
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " +
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
throw error("FIN_404", "费用来源不存在或不属于客户");
@@ -126,8 +153,8 @@ public class FinanceSettlementService {
Long supplierId = nullableNumber(source, "supplier_id");
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) {
throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用");
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
}
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
Integer used = jdbc.queryForObject(
@@ -137,12 +164,12 @@ public class FinanceSettlementService {
}
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
String supplierName = sources.stream().map(row -> text(row, "supplier_name"))
.filter(StringUtils::hasText).findFirst().orElse("供应商" + request.supplierId());
String supplierCategory = supplierCategoryConstraint(sources);
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName,
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
CNY, total, generatedDate, request.dueDate(), operatorId);
for (Map<String, Object> source : sources) {
@@ -158,30 +185,28 @@ public class FinanceSettlementService {
public SettlementDetail getSettlement(long settlementId) {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
Map<String, Object> row = one(
"SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id,supplier_name_snapshot," +
"reconciliation_statement_id,business_period,currency_code,total_amount,paid_amount,outstanding_amount," +
"generated_date,due_date,payment_status,status,version FROM fin_settlement WHERE id=?",
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
"结算单不存在", settlementId);
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " +
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList(
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_at,r.remark " +
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
}
List<Map<String, Object>> audits = jdbc.queryForList(
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " +
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
summary.customerId(), settlementId);
List<SettlementRevision> revisions = new ArrayList<>();
@@ -196,17 +221,14 @@ public class FinanceSettlementService {
if (customerId != null && customerId <= 0) {
throw error("FIN_400", "客户范围无效");
}
String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " +
"FROM fin_settlement WHERE direction=?";
String sql = SETTLEMENT_SUMMARY_SELECT + "WHERE s.direction=?";
List<Object> args = new ArrayList<>();
args.add(direction);
if (customerId != null) {
sql += " AND tenant_id=?";
sql += " AND s.tenant_id=?";
args.add(customerId);
}
sql += " ORDER BY generated_date DESC,id DESC";
sql += " ORDER BY s.generated_date DESC,s.id DESC";
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
}
@@ -215,7 +237,7 @@ public class FinanceSettlementService {
CreateSettlementPaymentRequest request) {
validatePaymentRequest(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " +
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
@@ -229,6 +251,8 @@ public class FinanceSettlementService {
if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突");
BigDecimal outstanding = decimal(settlement, "outstanding_amount");
if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额");
String supplierCategory = payableSupplierCategory(customerId, settlementId);
supplierValidator.requireActive(number(settlement, "supplier_id").longValue(), supplierCategory);
validateVoucherFiles(customerId, request.voucherFileIds());
insertPayment(customerId, settlementId, request, operatorId);
@@ -309,10 +333,11 @@ public class FinanceSettlementService {
private void insertPayment(long customerId, long settlementId, CreateSettlementPaymentRequest request,
long operatorId) {
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()), operatorId);
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
customerId, settlementId, transactionNo(), request.transactionDate(), request.amount(), CNY,
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
}
private void validatePayableRequest(CreatePayableSettlementRequest request) {
@@ -353,6 +378,21 @@ public class FinanceSettlementService {
voucherValidator.validate(customerId, fileIds, "付款凭证");
}
private String payableSupplierCategory(long customerId, long settlementId) {
return supplierCategoryConstraint(jdbc.queryForList(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
customerId, settlementId));
}
private String supplierCategoryConstraint(List<Map<String, Object>> sources) {
Set<String> categories = sources.stream().map(row -> text(row, "supplier_category"))
.filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
if (categories.size() > 1) throw error("FIN_409", "应付费用来源供应商类别约束不一致");
return categories.isEmpty() ? null : categories.iterator().next();
}
private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
@@ -398,17 +438,21 @@ public class FinanceSettlementService {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data," +
"operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_SETTLEMENT", resourceId, action, reason, before == null ? null : json(before),
json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId),
MDC.get("requestId"), MDC.get("traceId"));
}
private SettlementSummary summary(Map<String, Object> row) {
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "business_period"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
text(row, "business_period"), text(row, "fee_item_summary"),
text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"),
decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"),
text(row, "payment_status"), text(row, "status"), number(row, "version").intValue());
text(row, "payment_status"), text(row, "status"), number(row, "created_by").longValue(),
text(row, "created_by_name"), time(row, "created_at"), time(row, "updated_at"),
nullableTime(row.get("last_payment_at")), number(row, "version").intValue());
}
private SettlementLine line(Map<String, Object> row, SettlementSummary settlement) {
@@ -417,20 +461,24 @@ public class FinanceSettlementService {
BigDecimal amount = decimal(row, "amount");
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照");
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), firstText(jsonText(snapshot, "supplierName"), settlement.supplierName()),
supplierId, jsonText(snapshot, "supplierName"),
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true);
quantity, unitPrice, amount, text(row, "currency_code"), text(row, "review_status"),
text(row, "status"), true);
}
private SettlementTransaction transaction(Map<String, Object> row) {
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
text(row, "created_by_name"),
time(row, "created_at"), text(row, "remark"));
}
@@ -439,6 +487,7 @@ public class FinanceSettlementService {
JsonNode after = parseTree(text(row, "after_data"));
return new SettlementRevision(revisionNo, text(row, "action"), jsonDecimal(before, "totalAmount"),
jsonDecimal(after, "totalAmount"), text(row, "reason"), number(row, "operator_id").longValue(),
text(row, "operator_name_snapshot"),
time(row, "created_at"));
}
@@ -467,8 +516,8 @@ public class FinanceSettlementService {
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
private String documentNo(String prefix) {
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
private String transactionNo() {
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
}
@@ -534,6 +583,9 @@ public class FinanceSettlementService {
if (value instanceof LocalDateTime time) return time;
return value == null ? null : LocalDateTime.parse(value.toString());
}
private static LocalDateTime nullableTime(Object value) {
return value == null ? null : localTime(value);
}
private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
private static String jsonText(JsonNode node, String key) {
@@ -0,0 +1,53 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.WarehouseSupplierClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Set;
@Service
public class FinanceSupplierValidator {
private static final Set<String> CATEGORIES = Set.of("TRUCKING", "CUSTOMS", "BOOKING", "CERTIFICATE");
private final WarehouseSupplierClient client;
private final String internalToken;
public FinanceSupplierValidator(WarehouseSupplierClient client,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.client = client;
this.internalToken = internalToken;
}
public WarehouseSupplierClient.SupplierSnapshot requireActive(long supplierId, String category) {
String normalizedCategory = normalizeCategory(category);
try {
var response = client.get(internalToken, supplierId, true, normalizedCategory);
var supplier = response == null ? null : response.data();
if (response == null || !response.successful() || supplier == null
|| supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status())
|| (normalizedCategory != null && !normalizedCategory.equals(supplier.category()))
|| !StringUtils.hasText(supplier.name())) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
return supplier;
} catch (FeignException exception) {
if (exception.status() == 400 || exception.status() == 404 || exception.status() == 409) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
throw new FinanceEventException("FIN_503", "供应商主数据服务暂不可用");
}
}
private static String normalizeCategory(String category) {
if (!StringUtils.hasText(category)) return null;
String normalized = category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) {
throw new FinanceEventException("FIN_409", "费用来源供应商类别无效");
}
return normalized;
}
}
@@ -0,0 +1,103 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Service
public class ReceivableStatisticsService {
private static final String CURRENCY = "CNY";
private final JdbcTemplate jdbc;
public ReceivableStatisticsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
validate(month, customerId, page, pageSize);
List<ReceivableStatisticsRow> all = rows(month, customerId);
Totals totals = totals(all);
int from = (int) Math.min((long) (page - 1) * pageSize, all.size());
int to = Math.min(from + pageSize, all.size());
return new ReceivableStatisticsResponse(month, CURRENCY, totals.receivable(), totals.received(),
totals.outstanding(), all.size(), page, pageSize, List.copyOf(all.subList(from, to)));
}
public List<ReceivableStatisticsRow> exportRows(YearMonth month, Long customerId) {
validate(month, customerId, 1, 1);
List<ReceivableStatisticsRow> rows = rows(month, customerId);
totals(rows);
return rows;
}
private List<ReceivableStatisticsRow> rows(YearMonth month, Long customerId) {
LocalDate from = month.atDay(1);
LocalDate to = month.plusMonths(1).atDay(1);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, customer_name_snapshot,
SUM(total_amount) total_receivable,
SUM(paid_amount) total_received,
SUM(outstanding_amount) total_outstanding
FROM fin_settlement
WHERE direction='RECEIVABLE'
AND status<>'VOIDED'
AND currency_code='CNY'
AND generated_date>=?
AND generated_date<?
""");
List<Object> args = new ArrayList<>(List.of(from, to));
if (customerId != null) {
sql.append(" AND tenant_id=?");
args.add(customerId);
}
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
rs.getBigDecimal("total_outstanding"))), args.toArray());
}
private static Totals totals(List<ReceivableStatisticsRow> rows) {
BigDecimal receivable = BigDecimal.ZERO;
BigDecimal received = BigDecimal.ZERO;
BigDecimal outstanding = BigDecimal.ZERO;
for (ReceivableStatisticsRow row : rows) {
checked(row);
receivable = receivable.add(row.totalReceivable());
received = received.add(row.totalReceived());
outstanding = outstanding.add(row.totalOutstanding());
}
if (receivable.compareTo(received.add(outstanding)) != 0) inconsistent();
return new Totals(receivable, received, outstanding);
}
private static ReceivableStatisticsRow checked(ReceivableStatisticsRow row) {
if (row.customerId() <= 0 || row.customerName() == null || row.customerName().isBlank()
|| row.totalReceivable() == null || row.totalReceived() == null || row.totalOutstanding() == null
|| row.totalReceivable().signum() < 0 || row.totalReceived().signum() < 0
|| row.totalOutstanding().signum() < 0
|| row.totalReceivable().compareTo(row.totalReceived().add(row.totalOutstanding())) != 0) {
inconsistent();
}
return row;
}
private static void validate(YearMonth month, Long customerId, int page, int pageSize) {
if (month == null) throw new FinanceEventException("FIN-STAT-400", "统计月份不能为空");
if (customerId != null && customerId <= 0) throw new FinanceEventException("FIN-STAT-400", "客户ID必须为正整数");
if (page <= 0 || pageSize <= 0 || pageSize > 500) throw new FinanceEventException("FIN-STAT-400", "分页参数无效");
}
private static void inconsistent() {
throw new FinanceEventException("FIN-STAT-001", "应收统计金额勾稽异常,请核对正式结算数据");
}
private record Totals(BigDecimal receivable, BigDecimal received, BigDecimal outstanding) {}
}
@@ -1,6 +1,8 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
@@ -12,6 +14,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -33,7 +36,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
public class ReconciliationStatementService {
@@ -42,10 +44,18 @@ public class ReconciliationStatementService {
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers;
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -61,14 +71,15 @@ public class ReconciliationStatementService {
if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
String statementNo = documentNumbers.next("REC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?)",
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
statement.setString(2, statementNo);
statement.setLong(3, subjectId);
statement.setString(4, customer.name());
statement.setString(5, "CNY");
@@ -78,12 +89,13 @@ public class ReconciliationStatementService {
statement.setBigDecimal(9, total);
statement.setString(10, request.remark());
statement.setLong(11, operatorId);
statement.setLong(12, operatorId);
statement.setString(12, FinanceOperatorSnapshot.currentName(operatorId));
statement.setLong(13, operatorId);
statement.setString(14, FinanceOperatorSnapshot.currentName(operatorId));
return statement;
}, holder);
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(),
@@ -115,16 +127,24 @@ public class ReconciliationStatementService {
if ("VOID".equals(action)) requireVoidable(before.customerId(), statementId);
if ("REOPEN".equals(action) && "VOIDED".equals(before.status())) reacquireSources(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId);
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
transition.to(), request.reason(), operatorId, FinanceOperatorSnapshot.currentName(operatorId),
statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
before.customerId(), statementId, before.customerId());
} else if ("VOID".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
before.customerId(), statementId, before.customerId());
} else if ("REOPEN".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
}
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
@@ -146,8 +166,9 @@ public class ReconciliationStatementService {
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, statementId, before.customerId(), request.expectedVersion());
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId);
@@ -159,18 +180,63 @@ public class ReconciliationStatementService {
Bill bill = bill(customerId, statementId);
CreationMetadata metadata = creationMetadata(customerId, statementId);
List<ReconciliationLine> lines = jdbc.query(
"SELECT f.id,l.id statement_line_id,f.source_type,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
"SELECT f.id,l.id statement_line_id,f.source_type,f.source_no,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id," +
"JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) inbound_code,JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) container_no," +
"STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d') business_date," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.quantity')) AS DECIMAL(20,6)) quantity," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.unitPrice')) AS DECIMAL(20,6)) unit_price," +
"f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status " +
"FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
(rs, row) -> line(rs), customerId, statementId);
List<PaymentHistory> payments = jdbc.query(
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.created_by,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.createdBy(), bill.createdAt(), bill.updatedBy(), bill.updatedAt(), bill.remark(), lines, payments, revisions);
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
bill.customerConfirmedAt(),
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
@Transactional
public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId,
String key, CustomerConfirmationRequest request) {
if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) {
throw error("FIN_400", "客户确认请求无效");
}
Bill before = bill(customerId, statementId);
Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key,
"POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request);
if (replay != null) return customerConfirmation(customerId, replay);
if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) {
throw error("FIN_409", "仅已出账对账单允许客户确认");
}
if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) {
throw error("FIN_409", "对账单已确认或当前无需确认");
}
String operatorName = FinanceOperatorSnapshot.requiredName(operatorId);
int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?",
operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
CustomerConfirmationResult after = customerConfirmation(customerId, statementId);
audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId);
complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId);
return after;
}
private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) {
Bill current = bill(customerId, statementId);
if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null
|| current.customerConfirmedAt() == null) {
throw error("FIN_409", "对账单尚未完成客户确认");
}
return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(),
current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version());
}
public ReconciliationDetail detailForPlatform(long statementId) {
@@ -179,7 +245,7 @@ public class ReconciliationStatementService {
private List<Source> eligibleSources(CreateReconciliationRequest request) {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
List<Map<String, Object>> selected = jdbc.queryForList(
@@ -248,8 +314,9 @@ public class ReconciliationStatementService {
private void snapshot(Bill bill, String reason, long operatorId) {
List<Map<String, Object>> lines = jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", bill.customerId(), bill.id());
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId,
FinanceOperatorSnapshot.currentName(operatorId));
}
private void requireVoidable(long customerId, long statementId) {
@@ -301,7 +368,8 @@ public class ReconciliationStatementService {
Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "RECONCILIATION_STATEMENT", statementId, action, reason, before == null ? null : json(before),
after == null ? null : json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
after == null ? null : json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId),
MDC.get("requestId"), MDC.get("traceId"));
}
private static void validateCreate(CreateReconciliationRequest request) {
@@ -387,21 +455,41 @@ public class ReconciliationStatementService {
private static ReconciliationLine line(ResultSet rs) throws SQLException {
return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), rs.getString("direction"),
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("inbound_code"), rs.getString("container_no"),
rs.getDate("business_date") == null ? null : rs.getDate("business_date").toLocalDate(),
rs.getBigDecimal("quantity"), rs.getBigDecimal("unit_price"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
}
private static PaymentHistory payment(ResultSet rs) throws SQLException {
private PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at");
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
rs.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
stringList(rs.getString("voucher_file_ids")), rs.getLong("created_by"), rs.getString("created_by_name"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
}
private List<String> stringList(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
JsonNode node = objectMapper.readTree(value);
if (!node.isArray()) throw error("FIN_001", "收款凭证快照损坏");
List<String> result = new ArrayList<>();
node.forEach(item -> { if (item.isTextual() && StringUtils.hasText(item.asText())) result.add(item.asText()); });
return List.copyOf(result);
} catch (FinanceEventException exception) {
throw exception;
} catch (Exception exception) {
throw error("FIN_001", "收款凭证快照损坏");
}
}
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action"), amountFromJson(rs.getString("before_data")),
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
rs.getString("operator_name_snapshot"),
rs.getTimestamp("created_at").toLocalDateTime());
}
@@ -420,8 +508,11 @@ public class ReconciliationStatementService {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("updated_by_name"),
rs.getTimestamp("updated_at").toLocalDateTime());
}
@@ -446,8 +537,9 @@ public class ReconciliationStatementService {
return rs.wasNull() ? null : value;
}
private static String stableNo(long id) {
return "REC-" + String.format(Locale.ROOT, "%020d", id);
private static LocalDateTime timestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toLocalDateTime();
}
private static FinanceEventException error(String code, String message) {
@@ -462,6 +554,8 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
int creditDays, String remark, long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt) {}
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
int creditDays, String remark, long createdBy, String createdByName,
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
}
@@ -34,17 +34,21 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
"VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included',
"TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason',
"VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason");
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
"VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认' AFTER issued_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by',
"BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID' AFTER customer_confirmation_status");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by_name',
"VARCHAR(128) NULL COMMENT '客户确认人姓名快照' AFTER customer_confirmed_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_at',
"DATETIME(3) NULL COMMENT '客户确认时间' AFTER customer_confirmed_by_name");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
"CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload");
CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
@@ -62,8 +66,7 @@ UPDATE fin_fee_source
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
updated_at = COALESCE(updated_at, created_at),
review_status = COALESCE(review_status, 'PENDING'),
settlement_included = COALESCE(settlement_included, 0),
review_status = COALESCE(review_status, 'DRAFT'),
operation_version = COALESCE(operation_version, 0);
UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
@@ -88,7 +91,7 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason',
'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN
'operation_version','updated_at')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -96,6 +99,12 @@ BEGIN
AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_bill'
AND column_name IN ('customer_confirmation_status','customer_confirmed_by',
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_assert_schema();
+20 -4
View File
@@ -20,11 +20,9 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
settlement_included TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是',
settlement_reason VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
@@ -105,12 +103,18 @@ CREATE TABLE IF NOT EXISTS fin_bill (
remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
customer_confirmation_status VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认',
customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID',
customer_confirmed_by_name VARCHAR(128) NULL COMMENT '客户确认人姓名快照',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
@@ -163,6 +167,7 @@ CREATE TABLE IF NOT EXISTS fin_receipt (
void_reason VARCHAR(500) NULL COMMENT '收款作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -213,6 +218,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
void_reason VARCHAR(500) NULL COMMENT '成本作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -257,6 +263,7 @@ CREATE TABLE IF NOT EXISTS fin_bill_revision (
bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照',
line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照',
created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '执行调整的操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_revision (bill_id, version),
@@ -279,6 +286,13 @@ CREATE TABLE IF NOT EXISTS fin_idempotency_record (
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -326,6 +340,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
@@ -363,6 +378,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
@@ -52,7 +52,7 @@ class CustomerAccountSummaryBehaviorTest {
.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'", "f.review_status='APPROVED'", "f.settlement_included=1")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
@@ -25,6 +25,10 @@ class FinanceContractTest {
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
assertFalse(controller.contains("@PostMapping(\"/bills\")"));
assertFalse(controller.contains("/bills/{billId}/issue"));
assertFalse(service.contains("BILL_CREATE"));
assertFalse(service.contains("BILL_ISSUE"));
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
@@ -35,7 +39,6 @@ class FinanceContractTest {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
assertTrue(unified.contains("assert_finance_permissions"));
assertTrue(unified.contains("platform:finance:bill:issue"));
assertTrue(unified.contains("merchant:finance:statement:view"));
}
}
@@ -0,0 +1,53 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.finance.client.IamCustomerClient;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.baiyue.finance.service.FinanceEventException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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 FinanceCustomerAccountServiceTest {
@Test
void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
when(iam.page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
1, 20, 1, 1)));
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
assertThat(row.customerCode()).isEqualTo("TEN-001");
assertThat(row.contactName()).isEqualTo("Alice");
assertThat(row.contactPhone()).isEqualTo("13800138000");
assertThat(row.outstandingReceivable()).isZero();
assertThat(row.pendingBilling()).isZero();
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
.isEqualTo("FIN-REQ-400");
}
}
@@ -0,0 +1,51 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExportContractTest {
private static final List<String> TYPES = List.of(
"FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST",
"FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST",
"FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL",
"FINANCE_RECEIVABLE_STATISTICS");
@Test
void keepsOpenApiAndAllFileDefinitionsOnTheSameNineTypes() throws Exception {
String openApi = Files.readString(resolve("docs", "财务管理", "openapi", "finance-phase1-five-pages-v1.yaml"), StandardCharsets.UTF_8);
String application = Files.readString(resolve("baiyue-firstleg", "file", "src", "main", "resources", "application.yml"), StandardCharsets.UTF_8);
String local = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "local", "file-service.yml"), StandardCharsets.UTF_8);
String cn = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "cn", "file-service.yml"), StandardCharsets.UTF_8);
Yaml yaml = new Yaml();
assertThat((Object) yaml.load(openApi)).isNotNull();
assertThat((Object) yaml.load(application)).isNotNull();
assertThat((Object) yaml.load(local)).isNotNull();
assertThat((Object) yaml.load(cn)).isNotNull();
assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:")
.contains("customerId?, status?, settlementNo?")
.contains("所有 DETAIL 仅 settlementId")
.doesNotContain("DETAIL: settlementId, direction");
for (String type : TYPES) {
assertThat(openApi).contains("- " + type);
assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
}
}
private static Path resolve(String... segments) {
Path repository = Path.of("").toAbsolutePath().normalize();
while (repository != null && !Files.exists(repository.resolve("apps").resolve("admin-web"))) repository = repository.getParent();
if (repository == null) throw new IllegalStateException("repository root not found");
return repository.resolve(Path.of(segments[0], java.util.Arrays.copyOfRange(segments, 1, segments.length)));
}
}
@@ -5,6 +5,7 @@ import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,21 +31,25 @@ class FinanceFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
private final FinanceSupplierValidator supplierValidator = mock(FinanceSupplierValidator.class);
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper, supplierValidator);
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
}
@Test
void importsApprovedSettlementInboundFee() {
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
when(firstleg.inbound("test-token", 21, 31, 1)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).inbound("test-token", 21, 31);
verify(firstleg).inbound("test-token", 21, 31, 1);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@@ -52,10 +57,10 @@ class FinanceFeeSourceEventServiceTest {
void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31);
verify(firstleg).container("test-token", 41, 31, 1);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
}
@@ -64,26 +69,43 @@ class FinanceFeeSourceEventServiceTest {
FinanceFeeSourceEvent event = containerEvent("container-payable-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", true,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
.contains("\"partnerId\":71", "\"partnerName\":\"现有往来单位\"");
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
}
@Test
void importsPayableWithUnrestrictedActiveSupplier() {
FinanceFeeSourceEvent event = containerEvent("container-unrestricted-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-unrestricted", 1);
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).requireActive(71L, null);
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
@@ -94,7 +116,7 @@ class FinanceFeeSourceEventServiceTest {
void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
@@ -129,7 +151,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsSnapshotBelongingToAnotherCustomer() {
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
@@ -141,7 +163,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsApprovedButUnpublishedNonVoidedFee() {
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.consume(event))
@@ -194,16 +216,16 @@ class FinanceFeeSourceEventServiceTest {
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", null, null, 61L, "CUSTOMS",
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
}
private void assertInvalidAmount(String eventId, BigDecimal amount) {
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
assertThatThrownBy(() -> service.consume(event))
@@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -17,6 +18,7 @@ import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -39,25 +41,30 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest {
@Mock FinancePhase1OperationService operationService;
@Mock FinanceCustomerAccountService customerAccountService;
@Test
void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() {
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService);
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1);
var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1);
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE");
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
verify(operationService).listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE");
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9"));
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
}
@Test
@@ -93,6 +100,8 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.page().total()).isEqualTo(1);
assertThat(result.requestId()).isNotBlank();
assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
@Test
@@ -108,14 +117,93 @@ class FinancePhase1OperationBehaviorTest {
}
@Test
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
.thenReturn(1L);
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(9001L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(19L);
when(rs.getLong("inbound_id")).thenReturn(29L);
when(rs.getLong("container_id")).thenReturn(39L);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("source_no")).thenReturn("IN-9");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
assertThat(meta.pageSize()).isEqualTo(50);
assertThat(meta.total()).isEqualTo(1);
});
assertThat(result.data()).singleElement().satisfies(source -> {
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
}
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT id,bill_no"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(81L);
when(rs.getLong("created_by")).thenReturn(71L);
when(rs.getString("bill_no")).thenReturn("REC-81");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
when(rs.getString("remark")).thenReturn("八月账单");
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("5200.00"));
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-08-17 13:00:00"));
return List.of(mapper.mapRow(rs, 0));
});
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).singleElement().satisfies(entry -> {
assertThat(entry.businessType()).isEqualTo("SETTLEMENT_ISSUED");
assertThat(entry.documentType()).isEqualTo("BILL");
assertThat(entry.documentNo()).isEqualTo("REC-81");
assertThat(entry.occurredAt()).isEqualTo(LocalDateTime.of(2026, 8, 17, 13, 0));
assertThat(entry.operatorName()).isEqualTo("platform_admin");
});
}
@Test
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409");
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
@Test
@@ -129,33 +217,32 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.customerId()).isEqualTo(9L);
assertThat(detail.currency()).isEqualTo("CNY");
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
assertThat(detail.settlementIncluded()).isTrue();
}
@Test
void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception {
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
boolean included = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet("APPROVED", included, included ? 2 : 1), 0));
boolean changed = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 1));
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false);
assertThat(result.settlementIncluded()).isTrue();
assertThat(result.reviewStatus()).isEqualTo("DRAFT");
assertThat(result.operationVersion()).isEqualTo(2);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET settlement_included="),
eq(true), eq("进入结算"), eq(88L), eq(1));
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="),
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED"));
}
@Test
@@ -178,7 +265,7 @@ class FinancePhase1OperationBehaviorTest {
eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1);
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"),
eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(),
eq(71L), eq("operator-71"), any(), any())).thenReturn(1);
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
@@ -209,9 +296,12 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(700L);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("头程运费");
@@ -220,11 +310,9 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
when(rs.getString("review_status")).thenReturn(reviewStatus);
when(rs.getString("review_reason")).thenReturn(null);
when(rs.getString("settlement_reason")).thenReturn(null);
when(rs.getBoolean("settlement_included")).thenReturn(included);
when(rs.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
}
@@ -234,6 +322,7 @@ class FinancePhase1OperationBehaviorTest {
private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>();
private String accountListSql = "";
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
@@ -245,6 +334,7 @@ class FinancePhase1OperationBehaviorTest {
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.startsWith("SELECT c.customer_id")) {
accountListSql = sql;
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
@@ -16,7 +16,7 @@ class FinancePhase1PermissionContractTest {
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
"platform:finance:customer-account:list",
int.class, int.class, String.class, String.class);
int.class, int.class, String.class, String.class, Long.class);
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
"platform:finance:customer-account:list",
long.class, int.class, int.class,
@@ -27,10 +27,10 @@ class FinancePhase1PermissionContractTest {
"platform:finance:fee-source:review",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"platform:finance:fee-source:settlement",
assertEndpoint("editFinanceFeeSource", PutMapping.class,
"platform:finance:fee-source:edit",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest.class);
}
private static void assertEndpoint(String methodName,
@@ -71,6 +71,40 @@ class FinancePlatformListContractTest {
.contains("customerId");
}
@Test
void financeDetailsKeepOperatorIdsAndExposeNameSnapshots() {
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary.class,
"periodStart", "periodEnd", "lineCount", "sourceCount", "sourceAmount", "adjustmentAmount",
"lastReceiptAt", "submittedAt", "reviewedAt", "issuedAt");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.Statement.class,
"createdBy", "createdByName", "updatedBy", "updatedByName");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.BillRevision.class,
"createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail.class,
"createdBy", "createdByName", "updatedBy", "updatedByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine.class,
"direction", "sourceType", "sourceNo", "orderId", "inboundCode", "containerNo",
"businessDate", "quantity", "unitPrice");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory.class,
"voucherFileIds", "createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
"createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
"reconciliationStatementNo", "feeItemSummary", "createdBy", "createdByName", "createdAt",
"updatedAt", "lastPaymentAt");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine.class,
"orderId", "orderNo", "inboundId", "inboundCode", "containerId", "containerNo");
}
private static void assertRecordFields(Class<?> type, String... names) {
assertThat(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains(names);
}
private static Method find(String name) throws Exception {
return switch (name) {
case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class);
@@ -20,10 +20,11 @@ class FinancePlatformOptionalCustomerQueryTest {
service.feePool(null, null);
service.statements(null, false);
service.reconciliationStatements(null);
service.receivableSettlements(null, false);
service.payableSettlements(null);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?");
assertThat(call.args()).isEmpty();
});
@@ -36,10 +37,11 @@ class FinancePlatformOptionalCustomerQueryTest {
service.feePool(31L, null);
service.statements(31L, false);
service.reconciliationStatements(31L);
service.receivableSettlements(31L, false);
service.payableSettlements(31L);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
assertThat(call.args()).containsExactly(31L);
});
@@ -50,6 +52,7 @@ class FinancePlatformOptionalCustomerQueryTest {
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效");
}
@@ -11,11 +11,14 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -34,6 +37,7 @@ class FinanceReadApiBehaviorTest {
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Mock FinanceSettlementService settlementService;
@Mock ReconciliationStatementService reconciliationStatementService;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
@@ -42,7 +46,7 @@ class FinanceReadApiBehaviorTest {
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
var response = new ClientFinanceController(service, queryService).receipts(user);
var response = new ClientFinanceController(service, queryService, reconciliationStatementService).receipts(user);
verify(queryService).clientReceipts(23L);
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
@@ -50,7 +54,7 @@ class FinanceReadApiBehaviorTest {
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
new ClientFinanceController(service, queryService).bill(user, 88L);
new ClientFinanceController(service, queryService, reconciliationStatementService).bill(user, 88L);
verify(queryService).statement(23L, 88L, true);
}
@@ -76,7 +80,7 @@ class FinanceReadApiBehaviorTest {
settlementController.payableSettlements(11L);
verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false);
verify(queryService).reconciliationStatements(11L);
verify(settlementService).listSettlements("RECEIVABLE", 11L);
verify(settlementService).listSettlements("PAYABLE", 11L);
}
@@ -93,16 +97,16 @@ class FinanceReadApiBehaviorTest {
controller.statements(31L);
verify(queryService).feePool(null, null);
verify(queryService).statements(null, false);
verify(queryService).reconciliationStatements(null);
verify(settlementService).listSettlements("RECEIVABLE", null);
verify(settlementService).listSettlements("PAYABLE", null);
verify(queryService).statements(31L, false);
verify(queryService).reconciliationStatements(31L);
}
@Test
void clientListsAlwaysUseAuthenticatedCustomerScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
var controller = new ClientFinanceController(service, queryService);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.bills(user);
controller.statements(user);
@@ -112,4 +116,23 @@ class FinanceReadApiBehaviorTest {
verify(queryService).receivableSettlements(23L, true);
verifyNoMoreInteractions(queryService);
}
@Test
void clientConfirmationUsesOnlyAuthenticatedCustomerAndOperatorScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
CustomerConfirmationRequest request = new CustomerConfirmationRequest(4);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.confirmStatement(user, 88L, "confirm-key", request);
verify(reconciliationStatementService).confirmForCustomer(23L, 7L, 88L, "confirm-key", request);
}
@Test
void clientConfirmationHasIndependentWriteAuthority() throws Exception {
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class);
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
}
}
@@ -1,6 +1,7 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
@@ -77,7 +78,7 @@ class FinanceServiceValidationTest {
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
new FinanceVoucherValidator(null, ""));
new FinanceVoucherValidator(null, ""), mock(FinanceSupplierValidator.class));
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
@@ -128,7 +129,7 @@ class FinanceServiceValidationTest {
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), eq(50L), eq(9L));
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), isNull(), eq(50L), eq(9L));
}
@Test
@@ -233,11 +234,12 @@ class FinanceServiceValidationTest {
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(files, "internal-token"));
new FinanceVoucherValidator(files, "internal-token"), mock(FinanceSupplierValidator.class));
}
private static FinanceService service(JdbcTemplate jdbc) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceVoucherValidator.class));
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
}
}
@@ -13,7 +13,9 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -38,6 +40,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
@@ -100,10 +103,10 @@ class FinanceSettlementBehaviorTest {
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L));
assertThat(allSql.getValue()).contains("FROM fin_settlement WHERE direction=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost", "tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement WHERE direction=?", "tenant_id=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost");
assertThat(allSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?")
.doesNotContain("FROM fin_cost", "AND s.tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?", "AND s.tenant_id=?")
.doesNotContain("FROM fin_cost");
}
@Test
@@ -230,13 +233,16 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.of(
"id", 501L, "transaction_type", "PAYMENT", "transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11)),
"amount", new BigDecimal("40.00"), "method", "BANK_TRANSFER", "reference_no", "BANK-1",
"voucher_file_ids", "[\"91\"]", "created_by", 7L,
"created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0)), "remark", "首笔付款")));
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0))),
Map.entry("remark", "首笔付款"))));
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(10L), eq(88L))).thenReturn(List.of());
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
@@ -250,6 +256,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().outstandingAmount()).isEqualByComparingTo("60.00");
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any());
}
@@ -302,7 +309,8 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator);
SettlementDetail result = service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"));
@@ -310,6 +318,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(supplierValidator).requireActive(20L, null);
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@@ -346,7 +355,7 @@ class FinanceSettlementBehaviorTest {
@Test
void settlementDetailRejectsMissingResource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(999L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(999L))).thenReturn(List.of());
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404");
}
@@ -363,11 +372,13 @@ class FinanceSettlementBehaviorTest {
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, fileClient, supplierValidator);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
assertThat(result.summary().status()).isEqualTo("PAID");
verify(supplierValidator).requireActive(20L, "BOOKING");
assertThat(result.summary().outstandingAmount()).isZero();
}
@@ -424,6 +435,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("transaction_date", Date.valueOf("2026-08-11")), Map.entry("amount", new BigDecimal("40.00")),
Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "RCPT-701"),
Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("created_by", 7L),
Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 10:00:00")), Map.entry("remark", "首笔收款"))));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
@@ -432,6 +444,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
}
@Test
@@ -522,8 +535,26 @@ class FinanceSettlementBehaviorTest {
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
return service(jdbc, fileClient, supplierValidator());
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient,
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"));
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
}
private static FinanceSupplierValidator supplierValidator() {
FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class);
org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any()))
.thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
20L, "供应商20", "BOOKING", "ACTIVE"));
return supplierValidator;
}
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
@@ -545,7 +576,7 @@ class FinanceSettlementBehaviorTest {
}
private static void stubSettlementDetail(JdbcTemplate jdbc, Map<String, Object> settlement) {
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(settlement));
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
@@ -557,31 +588,40 @@ class FinanceSettlementBehaviorTest {
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
}
private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
return new java.util.HashMap<>(Map.ofEntries(
java.util.HashMap<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"),
Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"),
Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"),
Map.entry("business_period", "2026-08"), Map.entry("fee_item_summary", "拖车费"),
Map.entry("currency_code", "CNY"),
Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid),
Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")),
Map.entry("due_date", Date.valueOf("2026-09-10")),
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
Map.entry("version", version)));
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 09:00:00")),
Map.entry("updated_at", Timestamp.valueOf("2026-08-11 09:00:00")), Map.entry("version", version)));
row.put("reconciliation_statement_id", null);
row.put("reconciliation_statement_no", null);
row.put("last_payment_at", null);
return row;
}
private static SettlementDetail detail(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L,
"供应商20", null, "2026-08", "CNY", paid.add(outstanding), paid, outstanding,
"供应商20", null, null, "2026-08", "拖车费", "CNY", paid.add(outstanding), paid, outstanding,
LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status,
status, version), List.of(), List.of(), List.of());
status, 7L, "platform_admin", LocalDateTime.of(2026, 8, 11, 9, 0),
LocalDateTime.of(2026, 8, 11, 9, 0), null, version), List.of(), List.of(), List.of());
}
private static AuthenticatedUser user(Set<String> permissions) {
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(16);
assertThat(columnCount).isEqualTo(233);
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(243);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -70,4 +70,25 @@ class FinanceSqlCommentContractTest {
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
}
@Test
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unifiedPath = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertThat(baseline.split("created_by_name VARCHAR\\(128\\) NULL COMMENT '创建操作人名称快照'", -1))
.hasSize(4);
assertThat(baseline)
.contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'")
.contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'");
assertThat(unified)
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')");
}
}
@@ -0,0 +1,53 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.WarehouseSupplierClient;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSupplierValidator;
import feign.FeignException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceSupplierValidatorTest {
private final WarehouseSupplierClient client = mock(WarehouseSupplierClient.class);
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void acceptsMissingCategoryAndRejectsInvalidExplicitCategory() {
var unrestricted = new WarehouseSupplierClient.SupplierSnapshot(
20L, "任意启用供应商", "BOOKING", "ACTIVE");
when(client.get("internal-token", 20L, true, null)).thenReturn(ApiResponse.success(unrestricted));
assertThat(validator.requireActive(20L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 20L, true, null);
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
}
@Test
void requiresActiveWarehouseSupplierWithMatchingCategory() {
var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(supplier));
assertThat(validator.requireActive(20L, "TRUCKING").name()).isEqualTo("正式供应商");
verify(client).get("internal-token", 20L, true, "TRUCKING");
}
@Test
void rejectsMismatchedOrUnavailableSupplier() {
var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(mismatched));
assertCode(() -> validator.requireActive(20L, "TRUCKING"), "FIN_409");
when(client.get("internal-token", 21L, true, "BOOKING")).thenThrow(mock(FeignException.class));
assertCode(() -> validator.requireActive(21L, "BOOKING"), "FIN_503");
}
private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) {
assertThatThrownBy(call).isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code);
}
}
@@ -0,0 +1,39 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.ReceivableStatisticsController;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReceivableStatisticsControllerTest {
@Test
void exposesTheDedicatedReadEndpointAndRejectsNonMonthInput() throws Exception {
ReceivableStatisticsService service = mock(ReceivableStatisticsService.class);
ReceivableStatisticsResponse response = new ReceivableStatisticsResponse(YearMonth.of(2026, 7), "CNY",
BigDecimal.TEN, BigDecimal.ONE, BigDecimal.valueOf(9), 0, 1, 20, List.of());
when(service.query(YearMonth.of(2026, 7), null, 1, 20)).thenReturn(response);
ReceivableStatisticsController controller = new ReceivableStatisticsController(service);
assertThat(ReceivableStatisticsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/finance/v1/receivable-statistics");
assertThat(ReceivableStatisticsController.class.getMethod("query", String.class, Long.class, int.class, int.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:receivable-statistics:list')");
assertThat(controller.query("2026-07", null, 1, 20).data()).isSameAs(response);
verify(service).query(YearMonth.of(2026, 7), null, 1, 20);
assertThatThrownBy(() -> controller.query("2026/07", null, 1, 20))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("yyyy-MM");
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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 ReceivableStatisticsServiceTest {
@Test
@SuppressWarnings("unchecked")
void aggregatesOnlyTheApprovedFormalCnyDimensionsAndPaginatesCustomers() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
List<ReceivableStatisticsRow> rows = List.of(
row(10001, "客户A", "300.00", "100.00", "200.00"),
row(10002, "客户B", "80.00", "80.00", "0.00"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc);
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isEqualByComparingTo("380.00");
assertThat(result.totalReceived()).isEqualByComparingTo("180.00");
assertThat(result.totalOutstanding()).isEqualByComparingTo("200.00");
assertThat(result.totalCustomers()).isEqualTo(2);
assertThat(result.records()).containsExactly(rows.get(0));
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
}
@Test
@SuppressWarnings("unchecked")
void failsClosedWhenPersistedTotalsDoNotReconcile() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc)
.query(YearMonth.of(2026, 7), null, 1, 20))
.isInstanceOf(FinanceEventException.class)
.hasMessageContaining("勾稽异常");
}
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
new BigDecimal(received), new BigDecimal(outstanding));
}
}
@@ -3,9 +3,11 @@ package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.controller.ReconciliationStatementController;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -27,12 +29,14 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.never;
@@ -79,7 +83,7 @@ class ReconciliationStatementBehaviorTest {
@Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
@@ -94,15 +98,17 @@ class ReconciliationStatementBehaviorTest {
@Test
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
var result = target.create(71L, "create-key", request);
assertThat(result.statementId()).isEqualTo(501L);
assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501");
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.createdByName()).isEqualTo("platform_admin");
assertThat(result.updatedByName()).isEqualTo("platform_admin");
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
assertThat(result.lines()).hasSize(1);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
@@ -147,11 +153,11 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.status()).isEqualTo("PENDING_REVIEW");
assertThat(result.version()).isEqualTo(1);
assertThat(result.periodStart()).isEqualTo(LocalDate.of(2026, 8, 1));
verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L));
verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L), isNull());
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="),
eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L),
eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), eq("PENDING_REVIEW"),
eq("提交审核"), eq(71L), eq(501L), eq(9L), eq("DRAFT"), eq(0));
eq("提交审核"), eq(71L), isNull(), eq(501L), eq(9L), eq("DRAFT"), eq(0));
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any());
}
@@ -178,9 +184,99 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.status()).isEqualTo("ISSUED");
assertThat(result.version()).isEqualTo(3);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION'"), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
}
@Test
void customerConfirmationChecksScopeStateVersionAndWritesAudit() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
AtomicInteger billReads = new AtomicInteger();
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
boolean confirmed = billReads.getAndIncrement() > 0;
return List.of(mapper.mapRow(billResultSet("ISSUED", confirmed ? 4 : 3,
confirmed ? "CONFIRMED" : "PENDING_CONFIRMATION", confirmed ? 71L : null), 0));
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(result.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(result.confirmedBy()).isEqualTo(71L);
assertThat(result.version()).isEqualTo(4);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"),
eq(9L), eq("RECONCILIATION_STATEMENT"), eq(501L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), isNull(),
anyString(), anyString(), eq(71L), eq("操作人 71"), isNull(), isNull());
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"), requestHash.capture());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of(Map.of(
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L,
"updated_at", LocalDateTime.now())));
var replay = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(replay.confirmationStatus()).isEqualTo("CONFIRMED");
verify(jdbc, org.mockito.Mockito.times(1)).update(
org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
}
@Test
void customerConfirmationHidesCrossCustomerAndRejectsInvalidState() throws Exception {
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
verify(crossCustomer, never()).queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), (Object[]) any());
JdbcTemplate draft = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(draft.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("DRAFT", 0, "NOT_REQUIRED", null), 0));
});
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(draft, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), (Object[]) any());
JdbcTemplate stale = mock(JdbcTemplate.class, invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
Object sql = invocation.getArguments()[0];
return sql instanceof String value && value.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'") ? 0 : 1;
}
return RETURNS_DEFAULTS.answer(invocation);
});
when(stale.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("ISSUED", 3, "PENDING_CONFIRMATION", null), 0));
});
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
@Test
void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -230,7 +326,7 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill_line SET amount="),
eq(new BigDecimal("280.00")), eq("运费更正"), eq(601L), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="),
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), eq(501L), eq(9L), eq(3));
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), isNull(), eq(501L), eq(9L), eq(3));
}
@Test
@@ -453,21 +549,32 @@ class ReconciliationStatementBehaviorTest {
}
private static ResultSet billResultSet(String status, int version) throws Exception {
return billResultSet(status, version, "ISSUED".equals(status) ? "PENDING_CONFIRMATION" : "NOT_REQUIRED", null);
}
private static ResultSet billResultSet(String status, int version, String confirmationStatus, Long confirmedBy) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getLong("tenant_id")).thenReturn(9L);
when(rs.getString("bill_no")).thenReturn("REC-00000000000000000501");
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
when(rs.getString("status")).thenReturn(status);
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("customer_confirmation_status")).thenReturn(confirmationStatus);
when(rs.getLong("customer_confirmed_by")).thenReturn(confirmedBy == null ? 0L : confirmedBy);
when(rs.wasNull()).thenReturn(confirmedBy == null);
when(rs.getString("customer_confirmed_by_name")).thenReturn(confirmedBy == null ? null : "操作人 71");
when(rs.getInt("credit_days")).thenReturn(30);
when(rs.getString("remark")).thenReturn("八月对账");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
when(rs.getString("updated_by_name")).thenReturn("platform_admin");
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
when(rs.getTimestamp("customer_confirmed_at")).thenReturn(confirmedBy == null ? null : timestamp);
return rs;
}
@@ -481,4 +588,10 @@ class ReconciliationStatementBehaviorTest {
default -> 1;
};
}
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.finance.service.FinanceExportProviderService;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
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.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceExportProviderControllerTest {
@Test
void exposesOnlyTheInternalVersionedProviderAndRequiresTheServiceToken() {
FinanceExportProviderService service = mock(FinanceExportProviderService.class);
String token = "12345678901234567890123456789012";
FinanceExportProviderController controller = new FinanceExportProviderController(service, token);
controller.initializeToken();
ExportBatchRequest request = new ExportBatchRequest("9", "0", "PLATFORM", "7", 1, Map.of(), null, null, 100);
ExportBatchResponse response = new ExportBatchResponse(1, "sha256:test", "snapshot", List.of(), List.of(), "0", false, 0L);
when(service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request)).thenReturn(response);
assertThat(FinanceExportProviderController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/internal/exports/v1");
assertThat(controller.batch(token, FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request).data()).isSameAs(response);
verify(service).exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request);
assertThatThrownBy(() -> controller.batch("wrong", FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request))
.isInstanceOf(BizException.class);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(JdbcTemplate.class, () -> jdbc);
context.register(FinanceDocumentNumberGenerator.class);
context.refresh();
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
}
}
@Test
void formatsShortDailySequenceForAllThreeDocuments() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
eq("REC:20260818"));
}
@Test
void rejectsUnsupportedTypeAndOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("REC"))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
}
@@ -0,0 +1,139 @@
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;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
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 {
@Mock FinanceCustomerAccountService accountService;
@Mock FinancePhase1OperationService operationService;
@Mock FinanceQueryService queryService;
@Mock ReconciliationStatementService reconciliationService;
@Mock FinanceSettlementService settlementService;
@Mock ReceivableStatisticsService receivableStatisticsService;
private FinanceExportProviderService service;
@BeforeEach
void setUp() {
service = new FinanceExportProviderService(accountService, operationService, queryService,
reconciliationService, settlementService, receivableStatisticsService, new ObjectMapper(), "12345678901234567890123456789012");
service.initializeSigningKey();
}
@Test
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"),
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
new BigDecimal("30"), "运费",
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), 7L, "创建人A",
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
when(operationService.getFinanceFeeSource(81L)).thenReturn(source);
var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST,
request(Map.of("customerId", "10001", "containerNo", "CONT-31"), null, null));
var continued = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST,
request(Map.of("customerId", "10001", "containerNo", "CONT-31"), first.nextCursor(), first.snapshotToken()));
assertThat(first.columns()).isNotEmpty();
assertThat(first.rows()).hasSize(1);
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A");
assertThat(first.totalRows()).isEqualTo(1L);
assertThat(continued.columns()).isEmpty();
assertThat(continued.rows()).isEmpty();
assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken());
}
@Test
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
request(Map.of("page", 1), null, null))).isInstanceOf(BizException.class);
var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST,
request(Map.of("status", "ISSUED"), null, null));
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST,
request(Map.of("status", "VOIDED"), first.nextCursor(), first.snapshotToken())))
.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);
}
@Test
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
new ReceivableStatisticsRow(10001L, "客户A", "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"))));
var result = service.exportBatch(FinanceExportProviderService.RECEIVABLE_STATISTICS,
request(Map.of("month", "2026-07", "customerId", "10001"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> assertThat(row)
.containsEntry("statisticsMonth", "2026-07")
.containsEntry("customerName", "客户A")
.containsEntry("currency", "CNY")
.doesNotContainKeys("fbaDirect", "fbaSpecialLine", "roundingDiscount"));
verify(receivableStatisticsService).exportRows(YearMonth.of(2026, 7), 10001L);
}
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot) {
return request(parameters, cursor, snapshot, 100);
}
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot, int limit) {
return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, limit);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.finance.service;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceOperatorSnapshotTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void capturesAuthenticatedUsernameForMatchingOperator() {
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null,
"platform_admin", "ADMIN", "WEB", Set.of());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null));
assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("platform_admin");
assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("platform_admin");
assertThat(FinanceOperatorSnapshot.currentName(72L)).isNull();
}
@Test
void labelsSystemGeneratedEntriesWithoutInventingAnOperator() {
assertThat(FinanceOperatorSnapshot.currentName(0L)).isEqualTo("系统");
assertThat(FinanceOperatorSnapshot.currentName(71L)).isNull();
assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("操作人 71");
}
}
+20 -12
View File
@@ -2,7 +2,7 @@
## 职责与边界
- 负责履约任务/分配、入库单、测量/OCR、审核、FBA 计费、完成入库编排、客户在途柜量统计及国内出仓履约链路。
- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计及国内出仓履约链路。
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
@@ -12,10 +12,10 @@
|---|---|
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
@@ -27,11 +27,14 @@
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
@@ -39,19 +42,24 @@
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 测量审核→计费确认→费用发布→入库完成不得跳过门禁;状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
- 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。
- 数据库字段变更同时维护 `src/main/resources/db/phase3-pda-release-allocation-migration.sql` 和 `../deploy/sql/头程数据库统一增量-MySQL8.sql`。
- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
## 全量验收导航
本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、迁移和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。
本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "iam-service", contextId = "firstlegIamCustomerSnapshotClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamCustomerSnapshotClient {
@GetMapping("/finance-customers")
ApiResponse<PageResult<Customer>> page(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("page") int page,
@RequestParam("size") int size,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "customerId", required = false) Long customerId);
record Customer(long id, String tenantCode, String tenantName, String status,
String contactName, String contactPhone) {}
}
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.ResponseEntity;
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
@@ -15,5 +16,10 @@ public interface LoadingEvidenceFileClient {
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
}
@@ -31,16 +31,34 @@ public interface OrderCargoClient {
@PathVariable long tenantId,
@PathVariable long orderId);
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
ApiResponse<MeasurementDeclarationSnapshot> measurementDeclaration(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long orderId);
record MeasurementDeclarationSnapshot(
String referenceId, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark) {
String cargoRemark, String customerRemark, String latestInternalNote) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null);
}
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg,
originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg,
cargoRemark, null, null);
}
}
@@ -13,39 +13,44 @@ import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1")
public interface WarehousePalletLocationClient {
@PostMapping("/reservations")
@GetMapping("/pallets/snapshots")
ApiResponse<List<PalletSnapshot>> palletSnapshots(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
@PostMapping("/pallet-locations/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
@PostMapping("/pallet-locations/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
@PostMapping("/pallet-locations/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/transfer")
@PostMapping("/pallet-locations/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/settlements")
@PostMapping("/pallet-locations/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/settlements/target")
@GetMapping("/pallet-locations/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@GetMapping("/occupied-resources")
@GetMapping("/pallet-locations/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@@ -54,18 +59,19 @@ public interface WarehousePalletLocationClient {
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
BigDecimal totalVolumeCbm, List<PalletReference> pallets,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record PalletReference(long weightResultId, long palletId) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm,
int packageCount) {}
long operatorId, BigDecimal totalVolumeCbm, int packageCount) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
String idempotencyKey, long operatorId, String reason,
List<Long> locationIds) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
long palletId, String palletCode, BigDecimal palletCapacityCbm,
long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
@@ -90,6 +96,8 @@ public interface WarehousePalletLocationClient {
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
record PalletSnapshot(long palletId, String palletCode, String palletType,
BigDecimal capacityCbm, BigDecimal tareWeightKg) {}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
public interface WarehouseSupplierClient {
@GetMapping("/{supplierId}")
ApiResponse<Snapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam(required = false) String category);
record Snapshot(long supplierId, String name, String category, String status) {}
}
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@@ -27,17 +28,19 @@ public class InternalFinanceFeeSourceController {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long inboundId, @PathVariable long feeId) {
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.inbound(inboundId, feeId));
return ApiResponse.success(service.inbound(inboundId, feeId, sourceVersion));
}
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> container(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long containerId, @PathVariable long feeId) {
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.container(containerId, feeId));
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
}
private void authenticate(String token) {
@@ -9,6 +9,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
@@ -35,6 +36,8 @@ public class InternalInboundOrderController {
@Resource
private InboundOrderCreationService service;
@Resource
private InboundCartonLabelService cartonLabels;
@Resource
private UnifiedInboundQueryService unifiedQueryService;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -59,7 +62,9 @@ public class InternalInboundOrderController {
|| command.totalPackageCount() != null && command.totalPackageCount() < 1) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
}
return ApiResponse.success(service.createFromApprovedOrder(command));
long inboundId = service.createFromApprovedOrder(command);
cartonLabels.getOrCreate(command.tenantId(), inboundId);
return ApiResponse.success(inboundId);
}
@PostMapping("/order-preparing")
@@ -31,12 +31,12 @@ public class InternalPriceBookController {
}
@PostMapping("/channels/validate")
public ApiResponse<Void> validateChannel(
public ApiResponse<PriceBookChannelValidation.Response> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PriceBookChannelValidation.Request request) {
requireToken(token);
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode());
return ApiResponse.success(null);
return ApiResponse.success(service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(),
request.channelCode(), request.destinationType(), request.requireInboundCodePrefix()));
}
private void requireToken(String token) {
@@ -6,8 +6,10 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -55,6 +57,18 @@ public class PlatformContainerController {
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
}
@GetMapping("/{id}/pda/photos/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> downloadPdaPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(value.contentType()).contentLength(value.content().length).body(value.content());
}
@PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@@ -70,15 +84,21 @@ public class PlatformContainerController {
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
@Valid @RequestBody SimulationCreateRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createSimulation(user.userId(), user.username(), request));
}
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
@@ -127,8 +147,8 @@ public class PlatformContainerController {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@RequestPart("file") MultipartFile file,
@RequestPart("expectedVersion") @Min(0) int expectedVersion,
@RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(loadingLists.uploadContainerClearance(
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
@@ -76,7 +76,7 @@ public class PlatformContainerFeeDetailController {
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody AdjustRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, feeId, request.expectedSourceVersion(), user.userId(), request.values()));
loadingPlanId, feeId, user.userId(), request));
}
@PostMapping("/{feeId}/void")
@@ -79,7 +79,7 @@ public class PlatformFbaPricingController {
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整已发布入库费用,不回退入库状态")
description = "调整初算或已发布入库费用,不回退待分柜状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,

Some files were not shown because too many files have changed in this diff Show More