Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
@@ -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("******");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
|
||||
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
|
||||
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 新能力只有被多个模块稳定复用时才放入本模块。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+79
@@ -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) {}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 序列化保持一致。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
+861
-233
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))));
|
||||
|
||||
+52
@@ -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
@@ -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 = "客户只读查询结算应收")
|
||||
|
||||
+51
@@ -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));
|
||||
}
|
||||
}
|
||||
+65
-11
@@ -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) {}
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+151
-25
@@ -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) {}
|
||||
}
|
||||
+121
-27
@@ -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,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);
|
||||
|
||||
+5
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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");
|
||||
}
|
||||
}
|
||||
+139
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-15
@@ -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) {}
|
||||
}
|
||||
+7
-4
@@ -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) {
|
||||
|
||||
+6
-1
@@ -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")
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+26
-6
@@ -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));
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user