diff --git a/AGENTS.md b/AGENTS.md index 63a126216..cbee3f977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 或权限契约的改动。 -- 数据库只访问所属服务库。模块迁移位于 `/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。 +- 数据库只访问所属服务库。模块表结构位于 `/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 -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 +- Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -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 `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 diff --git a/Jenkinsfile b/Jenkinsfile index c9a2d0c64..b8adbf11d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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') { diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 9fc9e69b7..438112a60 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -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')]) { diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java index a981e9876..4c9070ed9 100644 --- a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java +++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java @@ -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 { diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java index def500edf..8f43954e7 100644 --- a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java @@ -24,6 +24,7 @@ public final class AuditLogResponses { String username, String appCode, String clientType, + String clientIp, String requestId, String traceId, String serviceName, diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java index b16fec9e1..d89b6527d 100644 --- a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java @@ -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()); } diff --git a/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java new file mode 100644 index 000000000..20debe129 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java @@ -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); + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java index 95f9dc7c9..b7f14cbab 100644 --- a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java @@ -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")); } diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java index 96cae3c54..d6f445396 100644 --- a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java @@ -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 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)); diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 2b49bd724..bc91720f2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -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。 diff --git a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java index b76468b14..792347408 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java @@ -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 event = ArgumentCaptor.forClass(AuditEvent.class); verify(sink).publish(event.capture()); diff --git a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java index 76be507e3..ab9ee8e3a 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java @@ -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("******"); + } } diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 56599c468..277a9fa7d 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -18,7 +18,7 @@ ## 修改与验证 -- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。 +- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 新能力只有被多个模块稳定复用时才放入本模块。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 0b00b4c7f..ec95783fb 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.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`。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java new file mode 100644 index 000000000..097bf69e0 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -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 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 items, String customerId, String orderType, List 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 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 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 skuPackages) {} + + /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + public record SkuPackageSnapshot(String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks) {} +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java new file mode 100644 index 000000000..0856e4da7 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -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); + } +} diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index a98583deb..59eed2647 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -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 序列化保持一致。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 666aab4ce..5829c1000 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -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`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index c19ff581f..23b2f7310 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index d1ba97791..837438c33 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -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:} diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml index 5bf4ba68c..a41070aa9 100644 --- a/config/nacos/cn/finance-service.yml +++ b/config/nacos/cn/finance-service.yml @@ -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:} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/cn/order-service.yml index ffcc1335b..5e4a505b3 100644 --- a/config/nacos/cn/order-service.yml +++ b/config/nacos/cn/order-service.yml @@ -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} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index 2fc6dd0a5..62818960e 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -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:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml index 3c78aee86..562d7e0a8 100644 --- a/config/nacos/local/finance-service.yml +++ b/config/nacos/local/finance-service.yml @@ -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:} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml index f46a32771..16d034278 100644 --- a/config/nacos/local/order-service.yml +++ b/config/nacos/local/order-service.yml @@ -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} diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh new file mode 100644 index 000000000..6819b9113 --- /dev/null +++ b/deploy/scripts/deploy-development-service.sh @@ -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 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index d29243ad8..40bdad59d 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -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" diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4d1f2a252..34990fa30 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', + inbound_code VARCHAR(40) NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -527,6 +527,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( expected_ship_date DATE NULL COMMENT '预计发运日期', customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', pickup_method VARCHAR(24) NULL COMMENT '取货方式', + requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间', receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓逻辑ID', receiving_warehouse_name VARCHAR(128) NULL COMMENT '下单时国内收货仓名称快照;主数据变化不回写', receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照', @@ -555,12 +556,48 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), - UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), + UNIQUE KEY uk_order_inbound_code (inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +-- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 +SET @order_inbound_code_nullable_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'inbound_code' AND is_nullable = 'NO' + ), + 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定''', + 'SELECT 1' +); +PREPARE order_inbound_code_nullable_stmt FROM @order_inbound_code_nullable_sql; +EXECUTE order_inbound_code_nullable_stmt; +DEALLOCATE PREPARE order_inbound_code_nullable_stmt; + +-- CREATE TABLE IF NOT EXISTS 不会给既有表补列,故显式幂等补齐预约提货时间。 +SET @order_requested_pickup_at_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'requested_pickup_at' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL COMMENT ''客户要求平台上门提货的预约时间'' AFTER pickup_method' +); +PREPARE order_requested_pickup_at_stmt FROM @order_requested_pickup_at_sql; +EXECUTE order_requested_pickup_at_stmt; +DEALLOCATE PREPARE order_requested_pickup_at_stmt; + +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT 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 (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; + CREATE TABLE IF NOT EXISTS ord_order_party ( id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -791,7 +828,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', - partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', + partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -802,20 +839,22 @@ CREATE TABLE IF NOT EXISTS wh_partner ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), - KEY idx_wh_partner_tenant_status(tenant_id,status,deleted) + KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', - data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', partner_type VARCHAR(32) NOT NULL COMMENT '稳定业务类型:CUSTOMER客户、SHIPPER发货方等', + data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', partner_type VARCHAR(32) NOT NULL COMMENT '稳定业务类型:CARRIER船公司、SHIPPER发货方、CONSIGNEE收货方', PRIMARY KEY(partner_id,partner_type), - KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id) + KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id), + CONSTRAINT chk_wh_partner_type_value CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) ) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型'; INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) -VALUES(191000000000000001,0,0,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') +VALUES(191000000000000001,0,0,'CAR-001','示例船公司','CARRIER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id),partner_name=VALUES(partner_name),status='ACTIVE'; INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type) VALUES -(191000000000000001,0,0,'CUSTOMER'), +(191000000000000001,0,0,'CARRIER'), (191000000000000001,0,0,'SHIPPER') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id); @@ -1104,6 +1143,22 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; +-- The built-in IT customer must be immediately usable for order acceptance tests. +-- These approved metadata rows intentionally have no document URL: initialization must not +-- point at qualification files that do not exist in File service storage. +INSERT INTO iam_tenant_qualification ( + id, tenant_id, qualification_type, certificate_no, document_url, + valid_from, valid_to, status, review_comment, deleted +) VALUES + (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), + (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), + certificate_no = VALUES(certificate_no), status = 'APPROVED', + review_comment = VALUES(review_comment), deleted = 0; + INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES @@ -1976,9 +2031,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg', color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -1988,11 +2042,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; -- 存量库补齐托盘重量、颜色字段(可重复执行)。 SET @pallet_weight_sql = IF((SELECT COUNT(*) FROM information_schema.columns @@ -2004,6 +2058,28 @@ SET @pallet_color_sql = IF((SELECT COUNT(*) FROM information_schema.columns 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', 'SELECT 1'); PREPARE pallet_color_stmt FROM @pallet_color_sql; EXECUTE pallet_color_stmt; DEALLOCATE PREPARE pallet_color_stmt; +-- 托盘是可复用主数据,不再用状态或活动唯一约束模拟库存资源。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_available') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_available', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_type') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_type', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'status') > 0, + 'ALTER TABLE wh_pallet DROP COLUMN status', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_warehouse') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_type') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', @@ -2055,19 +2131,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), KEY idx_location_reservation_expire (reservation_status, expires_at), KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; +) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', @@ -2094,12 +2168,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), @@ -2107,7 +2179,7 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; +) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; -- 头程入库单属于其他数据库,不建立跨库外键。 @@ -2187,10 +2259,6 @@ SET @reservation_needs_upgrade = IFNULL(( SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF(@reservation_needs_upgrade = 1, 'ALTER TABLE wh_location_reservation MODIFY COLUMN active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动预占唯一标记,A0 返回 NULL''', 'SELECT 1'); @@ -2204,18 +2272,11 @@ SET @occupancy_needs_upgrade = IF( EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_location_occupancy' - AND LOCATE('shared_overflow', generation_expression) > 0) - AND EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' - AND column_name = 'active_pallet_occupancy'), + AND LOCATE('shared_overflow', generation_expression) > 0), 0, 1); SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; DELIMITER $$ CREATE PROCEDURE add_location_overflow_column_if_missing( @@ -2241,29 +2302,28 @@ BEGIN END$$ DELIMITER ; -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'active_pallet_reservation', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' -); CALL add_location_overflow_column_if_missing( 'wh_location_occupancy', 'active_location_occupancy', 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' ); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_pallet_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' -); DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); + +-- 清理旧环境中的托盘活动资源约束;库位活动约束继续保留。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -- 托盘、库位、预占和占用均为平台公共仓储资源,存量测试数据统一迁移到平台作用域 0。 @@ -2367,7 +2427,7 @@ SET @inbound_code_exists := ( WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'inbound_code' ); SET @inbound_code_sql := IF(@inbound_code_exists = 0, - 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''客户提交后生成的入仓码,草稿为空'' AFTER order_no', + 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定'' AFTER order_no', 'SELECT 1'); PREPARE inbound_code_statement FROM @inbound_code_sql; EXECUTE inbound_code_statement; @@ -2376,15 +2436,27 @@ DEALLOCATE PREPARE inbound_code_statement; SET @inbound_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ord_order' - AND index_name = 'uk_order_tenant_inbound_code' + AND index_name = 'uk_order_inbound_code' ); SET @inbound_code_index_sql := IF(@inbound_code_index_exists = 0, - 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)', + 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_inbound_code (inbound_code)', 'SELECT 1'); PREPARE inbound_code_index_statement FROM @inbound_code_index_sql; EXECUTE inbound_code_index_statement; DEALLOCATE PREPARE inbound_code_index_statement; +SET @legacy_inbound_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' +); +SET @legacy_inbound_code_index_sql := IF(@legacy_inbound_code_index_exists > 0, + 'ALTER TABLE ord_order DROP INDEX uk_order_tenant_inbound_code', + 'SELECT 1'); +PREPARE legacy_inbound_code_index_statement FROM @legacy_inbound_code_index_sql; +EXECUTE legacy_inbound_code_index_statement; +DEALLOCATE PREPARE legacy_inbound_code_index_statement; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql -- ============================================================================ @@ -2400,7 +2472,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', + inbound_code VARCHAR(40) NOT NULL COMMENT '全平台唯一入仓码', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', @@ -2453,7 +2525,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code), + UNIQUE KEY uk_inbound_code (inbound_code), UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), @@ -2467,15 +2539,63 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_global_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_code' +); +SET @inbound_global_code_index_sql := IF(@inbound_global_code_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_code (inbound_code)', + 'SELECT 1'); +PREPARE inbound_global_code_index_statement FROM @inbound_global_code_index_sql; +EXECUTE inbound_global_code_index_statement; +DEALLOCATE PREPARE inbound_global_code_index_statement; + +SET @inbound_legacy_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_code' +); +SET @inbound_legacy_code_index_sql := IF(@inbound_legacy_code_index_exists > 0, + 'ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_code', + 'SELECT 1'); +PREPARE inbound_legacy_code_index_statement FROM @inbound_legacy_code_index_sql; +EXECUTE inbound_legacy_code_index_statement; +DEALLOCATE PREPARE inbound_legacy_code_index_statement; + UPDATE baiyue_order.ord_order o -LEFT JOIN baiyue_firstleg.fl_inbound_order i +JOIN baiyue_firstleg.fl_inbound_order i ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0 - SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id)) + SET o.inbound_code = i.inbound_code WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND (o.submitted_at IS NOT NULL OR o.status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED')) - AND o.inbound_code IS NULL; + AND o.inbound_code IS NULL + AND i.inbound_code IS NOT NULL + AND TRIM(i.inbound_code) <> ''; + +-- 非草稿订单若既无订单入仓码也无 Firstleg 权威入仓码,禁止用内部 ID 伪造业务键。 +DROP PROCEDURE IF EXISTS assert_submitted_order_inbound_code; +DELIMITER $$ +CREATE PROCEDURE assert_submitted_order_inbound_code() +BEGIN + IF EXISTS ( + SELECT 1 + FROM baiyue_order.ord_order o + WHERE o.deleted = 0 + AND o.status <> 'DRAFT' + AND (o.submitted_at IS NOT NULL + OR o.status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED')) + AND (o.inbound_code IS NULL OR TRIM(o.inbound_code) = '') + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '存在已提交订单缺少权威入仓码,请先修复订单/Firstleg业务事实后重跑'; + END IF; +END$$ +DELIMITER ; +CALL assert_submitted_order_inbound_code(); +DROP PROCEDURE IF EXISTS assert_submitted_order_inbound_code; -- 从未提交的草稿或由草稿直接取消的订单不应拥有入仓码。 UPDATE baiyue_order.ord_order @@ -2734,6 +2854,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -2741,7 +2866,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -2757,8 +2883,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -2771,7 +2898,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -2894,7 +3022,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', @@ -2946,6 +3074,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'; + CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -2986,11 +3118,16 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_pallet_allocation' + AND index_name = 'uk_pallet_allocation_pallet') > 0, + 'ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS fl_inbound_file ( id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3169,6 +3306,25 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', CALL phase2_measure_add_column('fl_inbound_measurement_item', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id', + 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Warehouse托盘ID'' AFTER file_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_code', + 'VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''托盘编码快照'' AFTER pallet_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_type', + 'VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘类型快照'' AFTER pallet_code'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_capacity_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''托盘容量CBM快照'' AFTER pallet_type'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_tare_weight_kg', + 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘皮重KG快照'' AFTER pallet_capacity_cbm'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER adjusted_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER after_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); + SET @has_measure_box_code_index = ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() @@ -3189,6 +3345,30 @@ ALTER TABLE fl_inbound_measurement_item DROP PROCEDURE IF EXISTS phase2_measure_add_column; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND index_name = 'idx_weight_inbound_pallet') = 0, + 'ALTER TABLE fl_inbound_weight_result ADD KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id)', + 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_revision' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_revision DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3200,7 +3380,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -3421,6 +3602,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', @@ -3429,6 +3612,26 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( KEY idx_price_channel_status (status, price_model) ) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录'; +DROP PROCEDURE IF EXISTS price_channel_add_column; +DELIMITER $$ +CREATE PROCEDURE price_channel_add_column(IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'baiyue_firstleg' AND TABLE_NAME = 'fl_price_channel_catalog' + AND COLUMN_NAME = p_column + ) THEN + SET @ddl = CONCAT('ALTER TABLE baiyue_firstleg.fl_price_channel_catalog ADD COLUMN ', p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL price_channel_add_column('inbound_code_prefix', + "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用' AFTER aliases_json"); +CALL price_channel_add_column('overseas_inbound_code_prefix', + "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀' AFTER inbound_code_prefix"); +DROP PROCEDURE IF EXISTS price_channel_add_column; + CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', @@ -3512,26 +3715,29 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel ( ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'; INSERT INTO fl_price_channel_catalog - (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status) + (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, + inbound_code_prefix, overseas_inbound_code_prefix, status) VALUES - (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), - (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), - (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), - (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), - (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'), - (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'), - (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), - (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), - (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), - (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), - (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), - (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), - (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), - (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), - (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') + (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'), + (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'), + (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'), + (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'), + (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'NYEXX', NULL, 'ACTIVE'), + (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'), + (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'), + (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'), + (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'), + (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'), + (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'), + (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'), + (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'), + (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'), + (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE') ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), price_model = VALUES(price_model), - source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status); + source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), + inbound_code_prefix = VALUES(inbound_code_prefix), + overseas_inbound_code_prefix = VALUES(overseas_inbound_code_prefix), status = VALUES(status); INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status) VALUES @@ -4427,6 +4633,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', + supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与', @@ -4455,8 +4662,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', - partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', - partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', + supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', business_date DATE NOT NULL COMMENT '业务发生日', fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照', trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照', @@ -4644,15 +4852,27 @@ DELIMITER ; CALL phase3_add_allocation_picked_count(); DROP PROCEDURE phase3_add_allocation_picked_count; +CREATE TABLE IF NOT EXISTS fl_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; + CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', + record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', + merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID', + customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围', + customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', @@ -4669,7 +4889,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', - terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + terminal_trucking_company_id BIGINT UNSIGNED NULL COMMENT '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司名称快照', loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', @@ -4688,13 +4909,74 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( PRIMARY KEY (id), UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), - KEY idx_container_booking_container (container_no), + UNIQUE KEY uk_container_booking_container_no (container_no), + KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), KEY idx_container_booking_voyage (voyage_no, updated_at), + CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')), + CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; +DELIMITER $$ +CREATE PROCEDURE phase3_container_real_unit_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='source_type') THEN + ALTER TABLE fl_container_booking ADD COLUMN source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' + COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜' AFTER cabinet_no; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='record_status') THEN + ALTER TABLE fl_container_booking ADD COLUMN record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' + COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史' AFTER source_type; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='merged_into_container_booking_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL + COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN + ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN + ALTER TABLE fl_container_booking ADD KEY idx_container_booking_merge (record_status,merged_into_container_booking_id); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_source') THEN + ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_source + CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_record_status') THEN + ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status + CHECK (record_status IN ('ACTIVE','MERGED')); + END IF; +END$$ +DELIMITER ; +CALL phase3_container_real_unit_columns(); +DROP PROCEDURE phase3_container_real_unit_columns; + +-- 触发器创建前先收敛旧库货柜供应商ID列;历史名称无法反推ID,保持NULL。 +DROP PROCEDURE IF EXISTS phase3_container_supplier_snapshot_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_container_supplier_snapshot_add_column() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_container_booking' + AND column_name='terminal_trucking_company_id') THEN + ALTER TABLE fl_container_booking + ADD COLUMN terminal_trucking_company_id BIGINT UNSIGNED NULL COMMENT '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空' AFTER max_weight_kg; + END IF; +END$$ +DELIMITER ; +CALL phase3_container_supplier_snapshot_add_column(); +DROP PROCEDURE phase3_container_supplier_snapshot_add_column; + CREATE TABLE IF NOT EXISTS fl_loading_plan ( id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', @@ -5210,10 +5492,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -5222,8 +5501,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -5235,9 +5512,9 @@ BEGIN OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -5248,7 +5525,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; @@ -5377,7 +5654,44 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN - IF NOT EXISTS ( + IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN + IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段'; + END IF; + ELSEIF NOT EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND item_status = 'PARTIAL' @@ -5412,7 +5726,6 @@ BEGIN OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) OR NOT (NEW.internal_remark <=> OLD.internal_remark) OR NOT (NEW.customer_remark <=> OLD.customer_remark) - OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) OR NOT (NEW.frozen_at <=> OLD.frozen_at) OR NOT (NEW.frozen_by <=> OLD.frozen_by) OR NOT (NEW.removed_by <=> OLD.removed_by) @@ -5735,7 +6048,7 @@ BEGIN WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', - 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN + 'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns @@ -6035,14 +6348,17 @@ ON DUPLICATE KEY UPDATE deleted = 0, updated_at = CURRENT_TIMESTAMP(3); --- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。 +-- 平台托盘初始化主数据;两个国内作业仓各初始化木托盘、塑料托盘各一条。 +-- 托盘是可复用主数据,不维护动态占用状态。 INSERT INTO wh_pallet ( id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, - weight_kg, color, status, remark, created_by, updated_by + weight_kg, color, remark, created_by, updated_by ) VALUES - (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), - (193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) + (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0), + (193000000000200102,0,193000000000200002,'TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0), + (193000000000200103,0,193000000000200001,'YW-TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0), + (193000000000200104,0,193000000000200002,'DG-TP-002','木托盘',1.200000,8.000,'黄色',NULL,0,0) ON DUPLICATE KEY UPDATE tenant_id = 0, warehouse_id = VALUES(warehouse_id), @@ -6052,14 +6368,82 @@ ON DUPLICATE KEY UPDATE color = VALUES(color), remark = VALUES(remark); --- 国内作业仓最小库位基线:每仓一个普通 A1 和一个系统共享溢出位 A0。 +DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed; +DELIMITER // +CREATE PROCEDURE assert_domestic_pallet_seed() +BEGIN + IF EXISTS ( + SELECT expected.warehouse_id + FROM ( + SELECT 193000000000200001 AS warehouse_id + UNION ALL SELECT 193000000000200002 + ) expected + LEFT JOIN wh_pallet pallet + ON pallet.tenant_id = 0 + AND pallet.warehouse_id = expected.warehouse_id + AND pallet.pallet_type IN ('木托盘','塑料托盘') + AND pallet.deleted = 0 + GROUP BY expected.warehouse_id + HAVING COUNT(DISTINCT pallet.pallet_type) <> 2 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整'; + END IF; +END// +DELIMITER ; +CALL assert_domestic_pallet_seed(); +DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed; + +-- 义乌仓初始化 A-F 六个库区,每区 A1-A15 共 90 个普通库位。 +-- A0 仍是系统共享溢出位;东莞仓继续保留最小 A1 + A0 基线。 -- 不初始化预占、占用或任何业务单据。 +INSERT INTO wh_location ( + id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, + location_type, max_weight_kg, shared_overflow, status, remark, created_by, updated_by +) +WITH RECURSIVE + zones AS ( + SELECT 0 AS zone_index + UNION ALL + SELECT zone_index + 1 FROM zones WHERE zone_index < 5 + ), + slots AS ( + SELECT 1 AS slot_number + UNION ALL + SELECT slot_number + 1 FROM slots WHERE slot_number < 15 + ) +SELECT + CAST(193000000000200000 + CASE + WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201 + ELSE 203 + zones.zone_index * 15 + slots.slot_number + END AS UNSIGNED), + 0, + 193000000000200001, + CHAR(65 + zones.zone_index), + CONCAT(CHAR(65 + zones.zone_index), '区'), + CONCAT(CHAR(65 + zones.zone_index), slots.slot_number), + CONCAT(CHAR(65 + zones.zone_index), slots.slot_number, ' 普通库位'), + 'PALLET',1000.000,0,'AVAILABLE',NULL,0,0 +FROM zones +CROSS JOIN slots +WHERE 1 = 1 +ON DUPLICATE KEY UPDATE + tenant_id = 0, + warehouse_id = VALUES(warehouse_id), + zone_code = VALUES(zone_code), + zone_name = VALUES(zone_name), + location_name = VALUES(location_name), + location_type = VALUES(location_type), + max_weight_kg = VALUES(max_weight_kg), + shared_overflow = VALUES(shared_overflow), + status = 'AVAILABLE', + remark = VALUES(remark), + deleted = 0; + INSERT INTO wh_location ( id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, location_type, max_weight_kg, shared_overflow, status, remark, created_by, updated_by ) VALUES - (193000000000200201,0,193000000000200001,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0), (193000000000200202,0,193000000000200001,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0), (193000000000200203,0,193000000000200002,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0), (193000000000200204,0,193000000000200002,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0) @@ -6076,6 +6460,35 @@ ON DUPLICATE KEY UPDATE remark = VALUES(remark), deleted = 0; +DROP PROCEDURE IF EXISTS assert_yiwu_location_seed; +DELIMITER // +CREATE PROCEDURE assert_yiwu_location_seed() +BEGIN + IF (SELECT COUNT(*) + FROM wh_location + WHERE tenant_id = 0 + AND warehouse_id = 193000000000200001 + AND deleted = 0 + AND shared_overflow = 0 + AND zone_code IN ('A','B','C','D','E','F') + AND location_code REGEXP '^[A-F]([1-9]|1[0-5])$' + AND LEFT(location_code, 1) = zone_code) <> 90 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '义乌仓 A-F 每区 15 个普通库位初始化不完整'; + END IF; + IF (SELECT COUNT(*) + FROM wh_location + WHERE tenant_id = 0 + AND warehouse_id = 193000000000200001 + AND location_code = 'A0' + AND shared_overflow = 1 + AND deleted = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '义乌仓系统共享溢出位 A0 初始化不完整'; + END IF; +END// +DELIMITER ; +CALL assert_yiwu_location_seed(); +DROP PROCEDURE IF EXISTS assert_yiwu_location_seed; + DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( warehouse_id_seed BIGINT UNSIGNED NOT NULL, @@ -6606,6 +7019,8 @@ END$$ DELIMITER ; CALL unified_publish_fee_add_column('fl_fee_item', 'fee_code', "VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope"); +CALL unified_publish_fee_add_column('fl_fee_item', 'supplier_category', + "VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商' AFTER fee_type_name"); ALTER TABLE fl_fee_item MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', COMMENT = '平台公共费用项目主数据'; @@ -7015,8 +7430,6 @@ CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'published_at', "DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name"); CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'inbounded_at', "DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at"); -CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'settlement_included', - "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at"); DROP PROCEDURE IF EXISTS unified_publish_fee_add_column; DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; @@ -7037,11 +7450,20 @@ DELIMITER ; CALL unified_publish_fee_add_index('fl_fee_item', 'uk_fee_item_code', 'UNIQUE KEY `uk_fee_item_code` (`fee_code`)'); CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source', - 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)'); + 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)'); CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published', 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + -- ============================================================================ -- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -8544,9 +8966,9 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', - status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', @@ -8559,17 +8981,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', + freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', - pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', - pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号', - pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间', - pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间', - change_reason VARCHAR(1000) NULL COMMENT '船期变更原因', - failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因', - cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因', remark VARCHAR(1000) NULL COMMENT '平台内部备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', @@ -8584,14 +8999,55 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN - ('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')), + ('DRAFT','RELEASED')), CONSTRAINT chk_ocean_booking_schedule CHECK - (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at), - CONSTRAINT chk_ocean_booking_pending_schedule CHECK - (pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL - OR pending_estimated_arrival_at >= pending_estimated_sailing_at) + (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. +ALTER TABLE fl_ocean_booking + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; + +-- Recreate triggers and the current two-state constraint on repeatable initialization. +DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_party_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_history_before_delete; +-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS. +SET @ocean_drop_status_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_status' +); +PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; +EXECUTE ocean_drop_status_stmt; +DEALLOCATE PREPARE ocean_drop_status_stmt; +ALTER TABLE fl_ocean_booking + MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', + MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', + ADD CONSTRAINT chk_ocean_booking_status CHECK + (status IN ('DRAFT','RELEASED')); + +SET @ocean_drop_failure_reason = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND column_name='failure_reason' +); +PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason; +EXECUTE ocean_drop_failure_stmt; +DEALLOCATE PREPARE ocean_drop_failure_stmt; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', @@ -8602,7 +9058,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), - CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY')) ) ENGINE=InnoDB COMMENT='订舱提单关系方快照'; @@ -8617,7 +9073,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement ( sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', PRIMARY KEY (id), KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id), - CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0) ) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求'; @@ -8632,7 +9088,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation ( PRIMARY KEY (id), UNIQUE KEY uk_ocean_allocation_container (container_booking_id), KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at), - CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id) REFERENCES fl_ocean_booking_container_requirement(id), CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) @@ -8652,7 +9108,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', PRIMARY KEY (id), KEY idx_ocean_history_booking (ocean_booking_id,created_at,id), - CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) + CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE ) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史'; CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation ( @@ -8669,7 +9125,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation ( PRIMARY KEY (id), UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key), KEY idx_ocean_operation_booking (ocean_booking_id,created_at), - CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希'; @@ -8677,15 +9133,11 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN - IF OLD.status IN ('RELEASED','CANCELLED') THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改'; + IF OLD.status = 'RELEASED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; END IF; IF OLD.status <> NEW.status AND NOT ( - (OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')) - OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')) - OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN + (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法'; END IF; END$$ @@ -8722,8 +9174,7 @@ BEGIN IF (SELECT ocean_booking_id FROM fl_ocean_booking_container_requirement WHERE id=NEW.requirement_id) <> NEW.ocean_booking_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱'; END IF; - IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN - ('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN + IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'RELEASED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次'; END IF; END$$ @@ -8750,12 +9201,9 @@ BEGIN WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit' - WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm' WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change' + WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' + WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' END) OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' @@ -8775,12 +9223,9 @@ INSERT INTO iam_permission VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0), (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0), - (5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0), - (5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0), - (5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0), (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0), - (5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0), - (5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0), + (5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0), + (5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0), (5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -8791,6 +9236,9 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); + USE baiyue_firstleg; @@ -8799,7 +9247,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID', appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号', outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID', warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID', customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键', customer_name VARCHAR(128) NULL COMMENT '客户名称快照', - carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话', + carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话', driver_name VARCHAR(64) NULL COMMENT '司机姓名', driver_phone VARCHAR(32) NULL COMMENT '司机联系电话', driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息', vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号', store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照', store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', @@ -8817,9 +9265,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision) ) ENGINE=InnoDB COMMENT='拖车状态与改约历史'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( - id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定', + id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他', file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id) + uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER')) ) ENGINE=InnoDB COMMENT='拖车附件文件引用'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', @@ -8848,10 +9296,18 @@ BEGIN END$$ DELIMITER ; +CALL phase3_truck_add_column( + 'bill_of_lading_no', + 'VARCHAR(128) NULL AFTER carrier_name' +); CALL phase3_truck_add_column( 'actual_arrived_warehouse_at', 'DATETIME(3) NULL AFTER planned_loading_at' ); +CALL phase3_truck_add_column( + 'actual_loading_at', + 'DATETIME(3) NULL AFTER actual_arrived_warehouse_at' +); CALL phase3_truck_add_column( 'actual_departed_at', 'DATETIME(3) NULL AFTER actual_loading_at' @@ -8919,7 +9375,7 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149 - AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 9 + AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154 AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN @@ -8930,7 +9386,7 @@ BEGIN JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') - AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 18 + AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12 OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code)) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id @@ -9283,7 +9739,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_supplement_request ( CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( id BIGINT UNSIGNED NOT NULL COMMENT '报关代理提交批次逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号', - batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号', + batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空', customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号', file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注', submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID', submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照', submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', PRIMARY KEY (id), @@ -9292,6 +9748,22 @@ CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED')) ) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本'; +-- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。 +DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch' + AND column_name='customs_agent_id') THEN + ALTER TABLE fl_customs_submission_batch + ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version; + END IF; +END$$ +DELIMITER ; +CALL phase3_customs_supplier_snapshot_add_column(); +DROP PROCEDURE phase3_customs_supplier_snapshot_add_column; + CREATE TABLE IF NOT EXISTS fl_customs_receipt ( id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID', receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号', @@ -10302,12 +10774,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), @@ -10370,6 +10848,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 '最后更新时间,数据修改时自动刷新', @@ -10420,6 +10899,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 '最后更新时间,数据修改时自动刷新', @@ -10464,6 +10944,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), @@ -10486,6 +10967,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和数据隔离键', @@ -10533,12 +11021,21 @@ 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')), CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount) ) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生'; +SET @has_receivable_statistics_index = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_finance' AND table_name='fin_settlement' + AND index_name='idx_fin_settlement_receivable_statistics'); +SET @ddl = IF(@has_receivable_statistics_index=0, + 'ALTER TABLE baiyue_finance.fin_settlement ADD KEY idx_fin_settlement_receivable_statistics(direction,currency_code,generated_date,status,tenant_id)', + 'SELECT 1'); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS fin_settlement_line ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -10570,6 +11067,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), @@ -10579,6 +11077,35 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment ( CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0) ) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录'; +DROP PROCEDURE IF EXISTS finance_add_operator_snapshot_column; +DELIMITER $$ +CREATE PROCEDURE finance_add_operator_snapshot_column( + IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN after_column VARCHAR(64) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column + ) THEN + SET @finance_operator_snapshot_ddl = CONCAT( + 'ALTER TABLE `', target_table, + '` ADD COLUMN `', target_column, '` VARCHAR(128) NULL COMMENT ''操作人名称快照'' AFTER `', after_column, '`' + ); + PREPARE finance_operator_snapshot_stmt FROM @finance_operator_snapshot_ddl; + EXECUTE finance_operator_snapshot_stmt; + DEALLOCATE PREPARE finance_operator_snapshot_stmt; + END IF; +END$$ +DELIMITER ; + +CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by'); +CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by'); +CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by'); +CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by'); +CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by'); +CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by'); +DROP PROCEDURE IF EXISTS finance_add_operator_snapshot_column; + USE baiyue_firstleg; -- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql -- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行) @@ -10654,12 +11181,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照', fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', - partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', - partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', + supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', - settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池', review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本', @@ -10674,22 +11201,52 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status), - KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status), + KEY idx_container_fee_review (tenant_id, review_status, source_status), CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'), CONSTRAINT chk_container_fee_amount CHECK (amount >= 0), CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')), CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED')) -) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; +) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance'; -CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id', - "BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); -CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name', - "VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); -CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id', - "BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); -CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name', - "VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); +CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( + id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用快照ID', + source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', + source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID', + container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', + container_no VARCHAR(64) NULL COMMENT '柜号快照', + fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID快照', + fee_item_code VARCHAR(50) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', + direction VARCHAR(16) NOT NULL COMMENT '费用方向', + supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照', + supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + business_date DATE NOT NULL COMMENT '费用业务日期', + amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', + currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + review_status VARCHAR(16) NOT NULL COMMENT '审核状态快照', + reviewed_at DATETIME(3) NULL COMMENT '审核时间快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version), + KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id) +) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照'; + +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', + "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', + "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', + "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', + "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', + "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', + "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; @@ -10708,13 +11265,13 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' - AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整'; + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' - AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整'; + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整'; END IF; END$$ DELIMITER ; @@ -10758,17 +11315,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', @@ -10786,8 +11347,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)); @@ -10812,7 +11372,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 @@ -10820,6 +11380,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(); @@ -11134,7 +11700,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_history', '不可 CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_item', '主子入仓单拆分数量台账'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_status_history', '入库单流程状态不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_transport_snapshot', '入库单关联运输信息只读快照'); -CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片OCR原值、人工修正值和确认重量'); +CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片、托盘皮重快照、OCR毛重和确认净重'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'OCR重量人工修正和确认的不可变版本历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_evidence', '订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_operation', '装柜与派送写操作幂等记录'); @@ -11200,11 +11766,11 @@ CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件 CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0'); CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的一托一库位占用事实记录'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的托盘和库位临时预占'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_rule', '客户海外仓费用规则'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_tier', '客户海外仓费用阶梯'); -CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '二期托盘基础资料和物理托盘实例'); +CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '可复用托盘标识和称重主数据,不承载动态资源状态'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'PDA已拣满托盘库位精确释放的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_settlement_operation', 'PDA拣货资源原子结算的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', '托盘库位归属转移的持久化幂等操作记录'); @@ -11755,7 +12321,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'booking_no CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'cabinet_no', '平台唯一内部柜号'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'capacity_cbm', '货柜容量m3'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_no', '集装箱号;确认前允许修订'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_type', '箱型,例如20GP、40GP、40HQ'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_type', '平台固定柜型:20GP、40GP、40HC、40NR、45HC'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_by_name', '创建人名称快照'); @@ -11774,6 +12340,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'max_weight CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'shipping_company_id', '船司ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'shipping_company_name', '船司名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'terminal_trucking_company', '码头拖车公司'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'terminal_trucking_company_id', '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'updated_at', '货柜资料最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'updated_by_name', '最后修改人名称快照'); @@ -11801,8 +12368,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'created CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'currency', '币种,首期固定CNY'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'direction', '费用方向:RECEIVABLE应收、PAYABLE应付'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_id', '应付Warehouse供应商ID快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_name', '应付Warehouse供应商名称快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_category', '费用项目供应商类别约束快照;为空表示任意启用供应商'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_code_snapshot', '费用项目稳定编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_id', '平台公共费用项目ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_name_snapshot', '费用项目名称快照'); @@ -11814,7 +12382,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'review_ CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewed_at', '审核时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_id', '审核人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_name', '审核人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'settlement_included', '是否参与财务结算来源池'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_status', '来源状态:ACTIVE有效、VOIDED作废'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_version', '费用来源版本;调整递增,作废携带当前版本'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'tenant_id', '客户ID和数据隔离键'); @@ -11943,6 +12510,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'visibility', CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_no', '全局唯一代理提交批次号'); CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_version', '同一报关协同单内的提交批次版本序号'); CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_name', '本批次接收报关资料的报关代理名称快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_id', '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_case_id', '所属报关协同单ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'external_reference', '报关代理或外部系统返回的业务参考号'); CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'file_snapshot_json', '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash'); @@ -11974,6 +12542,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'deleted', '逻辑 CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'description', '说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_code', '稳定费用编码;系统费用使用固定编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_type_name', '费用类型名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'supplier_category', 'PAYABLE供应商类别约束;为空表示任意启用供应商'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'id', '费用项目ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_payable', '提柜应付是否参与'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_receivable', '提柜应收是否参与'); @@ -12115,8 +12684,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_c CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_id', '入库单ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbounded_at', '入库完成时间快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'invoice_title', '发票抬头快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id', '应付Warehouse供应商ID快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name', '应付Warehouse供应商名称快照;应收为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category', '费用项目供应商类别约束快照;为空表示任意启用供应商'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'package_count', '件数快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'publication_status', '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'published_at', '发布时间'); @@ -12128,7 +12698,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'review_st CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewed_at', '审核时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_id', '审核人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_name', '审核人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'settlement_included', '是否参与结算'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_id', '来源计费快照ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_no', '来源费用单号'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_version', '来源计费快照版本'); @@ -12302,7 +12871,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'fee_review_sta CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'fee_status', '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'hold_flag', '当前是否扣货'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'id', '入库单ID(雪花ID)'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_code', '客户内唯一入仓码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_code', '全平台唯一入仓码'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_role', '入仓单层级:MASTER主单、CHILD子单'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbounded_at', '入库完成时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'master_order_id', '仅用于约束有效主单订单唯一性'); @@ -12480,13 +13049,19 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'create CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'deleted', '逻辑删除标记'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'file_id', '称重图片文件ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'final_weight_kg', '确认并统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'id', 'OCR重量结果ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_provider', 'OCR服务商编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_raw_text', 'OCR原始文本'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_unit', 'OCR识别原始单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_value', 'OCR识别原始重量值'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_capacity_cbm', '托盘容量CBM快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_code', '托盘编码快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_id', 'Warehouse托盘ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_tare_weight_kg', '托盘皮重KG快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_type', '托盘类型快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'recognition_status', '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'updated_at', '更新时间'); @@ -12497,9 +13072,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'afte CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'after_weight_value', '变更后原单位重量'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_unit', '变更前单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_value', '变更前原单位重量'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'final_weight_kg', '确认时统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'id', 'OCR重量修订记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operated_at', '操作时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_id', '操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_name', '操作人名称快照'); @@ -12724,8 +13300,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'bill_of_lading CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_id', '订舱代理逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'cancellation_reason', '取消订舱原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'change_reason', '船期变更原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_at', '订舱记录创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_by', '创建操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_by_name', '创建操作人姓名快照'); @@ -12737,19 +13311,14 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'failure_reason', '订舱失败原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_estimated_arrival_at', '待确认变更预计到港时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_estimated_sailing_at', '待确认变更预计开船时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_vessel_name', '待确认变更船名'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_voyage_no', '待确认变更航次号'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消'); +CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_at', '订舱记录最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by_name', '最后修改操作人姓名快照'); @@ -13004,6 +13573,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arr CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'bill_of_lading_no', '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id', '拖车服务商逻辑ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验'); @@ -13043,7 +13613,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'version', CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'volume_cbm', '预约货物体积,单位立方米'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'warehouse_id', '预约到仓的国内收货仓逻辑ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'appointment_id', '所属拖车预约ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'attachment_type', '附件类型编码;由业务动作约定'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'attachment_type', '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_hash', '文件内容SHA-256摘要'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_id', 'File服务文件逻辑ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_name', '上传时文件名快照'); @@ -13268,7 +13838,7 @@ CALL queue_column_comment('baiyue_order', 'ord_order', 'expected_ship_date', ' CALL queue_column_comment('baiyue_order', 'ord_order', 'express_company', '履约快递公司名称快照'); CALL queue_column_comment('baiyue_order', 'ord_order', 'express_tracking_no', '履约快递单号'); CALL queue_column_comment('baiyue_order', 'ord_order', 'id', '订单ID(雪花ID)'); -CALL queue_column_comment('baiyue_order', 'ord_order', 'inbound_code', '客户提交后生成的入仓码,草稿为空'); +CALL queue_column_comment('baiyue_order', 'ord_order', 'inbound_code', '全平台唯一入仓码;来源由全局模式决定,提交后固定'); CALL queue_column_comment('baiyue_order', 'ord_order', 'order_no', '客户范围内唯一订单号'); CALL queue_column_comment('baiyue_order', 'ord_order', 'order_role', '订单层级:MASTER主订单、CHILD子订单'); CALL queue_column_comment('baiyue_order', 'ord_order', 'parent_order_id', '直属父订单ID;主订单为空;禁止子订单嵌套'); @@ -13471,7 +14041,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'warehouse_id', ' CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_code', '库区编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_name', '库区名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_location_occupancy', '普通库位活动占用唯一标记'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_pallet_occupancy', '托盘活动占用唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_package_count', '本托盘分配箱数'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_volume_cbm', '本托盘分配体积CBM'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'business_id', '来源服务入库单ID'); @@ -13495,7 +14064,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'tenant_i CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'warehouse_id', '仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_pallet_reservation', '托盘活动预占唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_reservation', '普通库位活动预占唯一标记,A0 返回 NULL'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'business_id', '来源服务入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'confirmed_at', '确认占用时间'); @@ -13557,13 +14125,12 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_code', '平 CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_name', '托盘名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_type', '托盘类型'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'remark', '备注'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'status', '内部状态:AVAILABLE、RESERVED或OCCUPIED'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'tenant_id', '平台公共托盘固定使用 tenant_id=0'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'warehouse_id', '所属仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘重量kg'); +CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘皮重kg'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'business_id', '入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'completed_at', '完成时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'created_at', '创建时间'); @@ -13610,7 +14177,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'district', '区县' CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'id', '往来单位ID(雪花ID)'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_code', '平台内唯一往来单位编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_name', '往来单位名称'); -CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_type', 'CUSTOMER、SUPPLIER或BOTH'); +CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_type', '往来单位类型:CARRIER、SHIPPER或CONSIGNEE'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'postal_code', '邮政编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'province', '省或州'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'remark', '备注'); @@ -13759,6 +14326,47 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:Finance 来源事件或人工写操作幂等约束不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_pallet' AND column_name = 'status') + OR (table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') + OR (table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy'))) <> 0 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND index_name IN ('uk_pallet_active_reservation', 'uk_pallet_active_occupancy')) <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:托盘动态资源状态或活动唯一约束仍存在'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') + OR (table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy')) + AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:库位活动预占或占用唯一约束不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND ((table_name = 'fl_inbound_weight_result' AND column_name IN ( + 'pallet_id', 'pallet_code', 'pallet_type', 'pallet_capacity_cbm', + 'pallet_tare_weight_kg', 'gross_weight_kg', 'net_weight_kg')) + OR (table_name = 'fl_inbound_weight_revision' + AND column_name IN ('gross_weight_kg', 'net_weight_kg')))) <> 9 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_inbound_weight_result', 'fl_inbound_weight_revision') + AND column_name = 'final_weight_kg') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:称重托盘快照或毛重净重字段未正确收敛'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND index_name = 'uk_pallet_allocation_pallet') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:同票托盘唯一约束仍阻止托盘主数据复用'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_carton_label') <> 1 THEN @@ -14026,6 +14634,11 @@ BEGIN WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item + WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS ( SELECT 1 FROM baiyue_firstleg.fl_inbound_fee_detail detail @@ -14066,9 +14679,7 @@ INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0), - (5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0), (5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0), - (5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0), (5105014, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:adjust', '调整账单', NULL, 'POST', 50, 'ACTIVE', 0), (5105015, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:void', '作废账单', NULL, 'POST', 60, 'ACTIVE', 0), (5105016, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:receipt:view', '查看收款', NULL, 'GET', 70, 'ACTIVE', 0), @@ -14079,9 +14690,18 @@ VALUES (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0) + (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), + (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id +WHERE p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); +DELETE FROM baiyue_iam.iam_permission +WHERE tenant_id=0 AND app_code='PLATFORM' + AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); + -- Finance G1-G4 新权限只进入可分配权限目录,不默认授予平台管理员。 DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions; DELIMITER $$ @@ -14091,7 +14711,7 @@ BEGIN SELECT 1 FROM baiyue_iam.iam_permission WHERE (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') - OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') + OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit') OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create') OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review') OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue') @@ -14102,7 +14722,7 @@ BEGIN OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create') OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void') OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) - OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) + OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025) OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026) OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027) OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028) @@ -14129,8 +14749,8 @@ BEGIN '查看财务费用来源(已停用)', NULL, 'GET', 140, 'INACTIVE', 1, 1), (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), - (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', - '设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0), + (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit', + '编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 0), (5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0), (5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0), (5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0), @@ -14157,7 +14777,7 @@ BEGIN AND deleted = 0 AND permission_code IN ( 'platform:finance:fee-source:review', - 'platform:finance:fee-source:settlement', + 'platform:finance:fee-source:edit', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:issue', @@ -14187,8 +14807,7 @@ INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance', - 'platform:finance:bill:create', - 'platform:finance:bill:update', 'platform:finance:bill:issue', + 'platform:finance:bill:update', 'platform:finance:bill:adjust', 'platform:finance:bill:void', 'platform:finance:receipt:view', 'platform:finance:receipt:create', 'platform:finance:cost:view', 'platform:finance:cost:create', @@ -14197,7 +14816,7 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( - 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement', + 'platform:finance:fee-source:review', 'platform:finance:fee-source:edit', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', @@ -14319,31 +14938,28 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; @@ -14654,15 +15270,27 @@ VALUES (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), - (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0) + (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), + (5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0), + (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), + (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), + (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), + (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'); DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' -WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list'); -UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); +UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' +WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') + AND p.permission_code='merchant:finance:statement:confirm'; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) @@ -14691,7 +15319,7 @@ USE baiyue_warehouse; CREATE TABLE IF NOT EXISTS wh_supplier ( id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证', - scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区', + region VARCHAR(80) NOT NULL COMMENT '地区', legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人', phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址', business_license_no VARCHAR(64) NULL COMMENT '营业执照号', license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID', @@ -14701,7 +15329,6 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region), CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')), - CONSTRAINT chk_wh_supplier_scope CHECK((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)), CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')), CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案'; @@ -14714,7 +15341,6 @@ BEGIN END IF; END$$ DELIMITER ; -CALL supplier_add_column_if_missing('scope',"VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category"); CALL supplier_add_column_if_missing('license_file_id',"VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no"); CALL supplier_add_column_if_missing('created_by',"BIGINT UNSIGNED NULL COMMENT '创建人ID'"); CALL supplier_add_column_if_missing('created_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'"); @@ -14735,7 +15361,6 @@ BEGIN END$$ DELIMITER ; CALL supplier_add_check_if_missing('chk_wh_supplier_category',"category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')"); -CALL supplier_add_check_if_missing('chk_wh_supplier_scope',"(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)"); CALL supplier_add_check_if_missing('chk_wh_supplier_status',"status IN ('ACTIVE','INACTIVE')"); CALL supplier_add_check_if_missing('chk_wh_supplier_settlement',"settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')"); DROP PROCEDURE supplier_add_check_if_missing; @@ -14883,17 +15508,20 @@ DROP PROCEDURE IF EXISTS assert_finance_permissions; DELIMITER $$ CREATE PROCEDURE assert_finance_permissions() BEGIN - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27 THEN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 28 THEN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; END IF; END$$ DELIMITER ; diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index f54206504..59c29eeec 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -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`。 diff --git a/file/AGENTS.md b/file/AGENTS.md index f392a6bf6..47a1deec8 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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`,不得包装成业务错误。 ## 全量验收导航 diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java index 3bd7ddf27..4fcb7d5df 100644 --- a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java @@ -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 batch(String internalToken, String exportType, diff --git a/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java new file mode 100644 index 000000000..63f2e8942 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java @@ -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 { } diff --git a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java index 3b6bc0713..bec3b2a44 100644 --- a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java +++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java @@ -11,10 +11,10 @@ import java.util.regex.Pattern; @Component @ConfigurationProperties(prefix = "baiyue.file.export") public class ExportDefinitionRegistry { - private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service"); + private static final Set 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 definitions = Map.of(); @PostConstruct diff --git a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java index d40d27952..bd34ddc72 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java @@ -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 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; + } + } } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java index 71b5261ef..b7901c4ea 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java @@ -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; + } + } } diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 79ac974ff..d677f54ed 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -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: diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java index 0fa8ba91a..fd86f9e01 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java @@ -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); } } diff --git a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java index 3fd4e4ffa..9df6f6712 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java @@ -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")))); diff --git a/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java new file mode 100644 index 000000000..6fb7bfd37 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java @@ -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); + } +} diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 5e8227ef2..c9d1f04b2 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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 导出中心直接拼装业务文件。 ## 验证 diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java index 074c74b17..3116463ee 100644 --- a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java @@ -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 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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java new file mode 100644 index 000000000..c0591cbe2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java @@ -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> 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) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java new file mode 100644 index 000000000..674af5986 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java @@ -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 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java index a98216343..f266046d7 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java @@ -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 = "客户只读查询结算应收") diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java new file mode 100644 index 000000000..9ccc627b9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java @@ -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 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)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index a9fd3597f..c21a6a710 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -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)); } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 6ee28c796..2d2e4862a 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java new file mode 100644 index 000000000..a8f88c608 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java @@ -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 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"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index 0ac962f23..a25c3fb6c 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -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 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); diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index 855301b0f..a93446a2c 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index ccca2b5db..4b4621849 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -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 voucherFileIds, long createdBy, LocalDateTime createdAt, + List 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 lines, List transactions, diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java new file mode 100644 index 000000000..975be2937 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java @@ -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 records) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java index 5483dcd86..6c923a0af 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos { public record ReconciliationActionRequest(String action, String reason, int expectedVersion, List 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 lines, List paymentHistory, List 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 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) {} } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java new file mode 100644 index 000000000..55efe872a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -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 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> 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 customers = response.data(); + Map metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList()); + List 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 loadMetrics(List customerIds) { + if (customerIds.isEmpty()) return Map.of(); + String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); + Map 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 frozen = new HashMap<>(); + result.forEach((id, value) -> frozen.put(id, value.freeze())); + return frozen; + } + + private static void metric(Map result, Map 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); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java new file mode 100644 index 000000000..2a98556fc --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -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 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); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java new file mode 100644 index 000000000..9b51034cb --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -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 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 SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); + private static final Set 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 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 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 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 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 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 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 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 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> 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> receivableStatisticsRows(Map 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> feeRows(Map 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> 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> statementRows(Map 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> 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> settlementRows(String direction, Map 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> settlementDetailRows(String direction, Map 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 parameters = request.parameters() == null ? Map.of() : request.parameters(); + Set 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 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 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 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 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 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 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 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 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 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 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 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 row(Object... values) { + Map 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 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 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 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 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 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 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 parameters, int offset, String snapshotToken) {} + private record BatchRows(List> rows, long totalRows) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index 69d61ead8..816201624 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -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", "柜子归属不一致"); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java new file mode 100644 index 000000000..8a70b1891 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java @@ -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; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index bafab4e56..40ed549f8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) { + public PageEnvelope 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 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 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 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 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 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 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 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 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 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 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); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 7086b4c1d..3ba0a80bf 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -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 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 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 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 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 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 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 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("客户范围无效"); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 917a1bc1a..b707dffec 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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 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 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> listReceipts(Long tenantId) { @@ -109,47 +105,6 @@ public class FinanceService { return customerTerms(tenantId); } - @Transactional - public Map 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 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 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 adjustBill(long operatorId,long billId,List 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 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 adjustBill(long operatorId,long billId,List 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 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 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 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 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 lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set 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 lines) { lineTotal(lines); Map 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 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 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 voucherFileIds, String remark, List 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 voucherFileIds, String remark) {} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index a81311e13..97c6f1df4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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 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 args = new ArrayList<>(); args.addAll(ids); List> 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 source : sources) { @@ -158,30 +185,28 @@ public class FinanceSettlementService { public SettlementDetail getSettlement(long settlementId) { if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); Map 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 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 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> 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 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 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 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> sources) { + Set 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 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 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 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) { diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java new file mode 100644 index 000000000..b719823be --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java @@ -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 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; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java new file mode 100644 index 000000000..e3edb6d99 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -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 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 exportRows(YearMonth month, Long customerId) { + validate(month, customerId, 1, 1); + List rows = rows(month, customerId); + totals(rows); + return rows; + } + + private List 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 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 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index a8c8db808..cb2a5669e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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 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 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 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 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 eligibleSources(CreateReconciliationRequest request) { List 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> selected = jdbc.queryForList( @@ -248,8 +314,9 @@ public class ReconciliationStatementService { private void snapshot(Bill bill, String reason, long operatorId) { List> 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 stringList(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + JsonNode node = objectMapper.readTree(value); + if (!node.isArray()) throw error("FIN_001", "收款凭证快照损坏"); + List 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) {} } diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql index f9d2487eb..03ee5011f 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -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(); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 22a0ab780..6d58b8f06 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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), diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 2635ea67c..573be27cd 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -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"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 7ad137d03..78cfdceab 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -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")); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java new file mode 100644 index 000000000..60e14fd13 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java new file mode 100644 index 000000000..82ddcf61f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -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 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))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index aee0b8c8d..c3cbbb946 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -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 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)) diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 26993080a..010ec0274 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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 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 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 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 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 ledgerCustomerIds = new ArrayList<>(); + private String accountListSql = ""; @Override public T queryForObject(String sql, Class requiredType, Object... args) { @@ -245,6 +334,7 @@ class FinancePhase1OperationBehaviorTest { @SuppressWarnings("unchecked") public List query(String sql, RowMapper rowMapper, Object... args) { if (sql.startsWith("SELECT c.customer_id")) { + accountListSql = sql; return (List) List.of(new FinancePhase1Dtos.AccountSummary( 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java index 7d974366c..7bc05f727 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java @@ -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, diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index 2a4680bd0..0a2e44594 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -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); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java index fa89a31dc..568c5e3dd 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -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("客户范围无效"); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java index 593e7dc57..258384877 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -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')"); + } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 971a49cf2..23a19d81b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -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)); } + } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index df28cad86..333cf48e4 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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 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 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 settlementRow(String direction, String status, BigDecimal paid, BigDecimal outstanding, int version) { - return new java.util.HashMap<>(Map.ofEntries( + java.util.HashMap 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 permissions) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index aa36ba141..8c3fb1143 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -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')"); + } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java new file mode 100644 index 000000000..39762271e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java @@ -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); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java new file mode 100644 index 000000000..c1cb850bf --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java new file mode 100644 index 000000000..e583b3d81 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -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 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 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)); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index ce1111c58..1064e9b0f 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -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 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 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 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 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); + } } diff --git a/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java new file mode 100644 index 000000000..46c77df04 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java @@ -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); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..26720351a --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java new file mode 100644 index 000000000..6ead12ece --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -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 parameters, String cursor, String snapshot) { + return request(parameters, cursor, snapshot, 100); + } + + private ExportBatchRequest request(Map parameters, String cursor, String snapshot, int limit) { + return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, limit); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java new file mode 100644 index 000000000..973afbb79 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java @@ -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"); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 47b35feb5..bb1b71671 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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 在线更新安排开发与验收,不另设设备型号门禁。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java new file mode 100644 index 000000000..775caebbf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java @@ -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> 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java index ec120df69..7568c3de9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java @@ -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 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index e4f361d90..f3a5683a5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -31,16 +31,34 @@ public interface OrderCargoClient { @PathVariable long tenantId, @PathVariable long orderId); + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") + ApiResponse 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); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 86d0e8481..a19a4a705 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -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> palletSnapshots( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("warehouseId") long warehouseId); + + @PostMapping("/pallet-locations/reservations") ApiResponse reserve(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReserveRequest request); - @PostMapping("/confirm") + @PostMapping("/pallet-locations/confirm") ApiResponse confirm(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ConfirmRequest request); - @PostMapping("/release") + @PostMapping("/pallet-locations/release") ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReleaseRequest request); - @PostMapping("/release-selected") + @PostMapping("/pallet-locations/release-selected") ApiResponse> releaseSelected( @RequestHeader("X-Internal-Service-Token") String token, @RequestBody SelectiveReleaseRequest request); - @PostMapping("/transfer") + @PostMapping("/pallet-locations/transfer") ApiResponse> transfer(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody TransferRequest request); - @PostMapping("/settlements") + @PostMapping("/pallet-locations/settlements") ApiResponse settle(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody SettlementRequest request); - @GetMapping("/settlements/target") + @GetMapping("/pallet-locations/settlements/target") ApiResponse 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> 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 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 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 allocations) {} record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, @@ -90,6 +96,8 @@ public interface WarehousePalletLocationClient { List remainingItems) {} record SettlementResult(List released, List 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java new file mode 100644 index 000000000..da7ca114b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java @@ -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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java index 3c0c116ba..f56321de8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java @@ -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 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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 3f8f4dbc8..651269bd1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 0743eb962..54fb3d8a9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -31,12 +31,12 @@ public class InternalPriceBookController { } @PostMapping("/channels/validate") - public ApiResponse validateChannel( + public ApiResponse 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 8b9090db0..0decb1063 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -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(@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 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 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 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 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 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 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 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 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 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)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java index e545498d0..b0a0769bc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java @@ -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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index 12eea8342..57813a240 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -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 adjustPublished( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java index f4ff30e34..d9feb89f7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java @@ -41,8 +41,10 @@ public class PlatformInboundFeeDetailController { public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId, + @RequestParam(defaultValue = "false") boolean includeHistory, @PathVariable long inboundId) { - return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId), inboundId)); + return ApiResponse.success(service.list( + FirstlegScopeGuard.platform(user, accountId), inboundId, includeHistory)); } @PostMapping diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 058850f70..383f8f891 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -33,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + /** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */ @Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。") @RestController @@ -53,6 +55,19 @@ public class PlatformInboundMeasurementController { return ApiResponse.success(service.detail(tenantId, inboundId)); } + @Operation(summary = "查询称重可选托盘快照", + description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。") + @GetMapping("/weight-pallet-options") + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')") + public ApiResponse> weightPalletOptions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId)); + } + @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") @@ -114,7 +129,7 @@ public class PlatformInboundMeasurementController { @RequestPart("image") MultipartFile image) { requirePlatformTenant(user, tenantId); CartonMeasurementCommand command = new CartonMeasurementCommand( - request.boxCode(), request.length(), request.width(), request.height(), request.weight(), + request.boxCode(), request.length(), request.width(), request.height(), request.machineNo(), request.measuredAt(), image, request.scannedShipmentId()); return ApiResponse.success(cartonMeasurements.upsertForInbound( tenantId, user.userId(), inboundId, command)); @@ -134,7 +149,7 @@ public class PlatformInboundMeasurementController { @RequestPart("image") MultipartFile image) { requirePlatformTenant(user, tenantId); CartonMeasurementCommand command = new CartonMeasurementCommand( - request.boxCode(), request.length(), request.width(), request.height(), request.weight(), + request.boxCode(), request.length(), request.width(), request.height(), request.machineNo(), request.measuredAt(), image, request.scannedShipmentId()); return ApiResponse.success(cartonMeasurements.upsertManualForInbound( tenantId, user.userId(), inboundId, command)); @@ -142,7 +157,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND", resourceId = "#inboundId", description = "登记称重图片 OCR 原值") - @Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。") + @Operation(summary = "登记 OCR 重量结果", + description = "按图片文件 ID 和托盘 ID 保存 OCR 毛重原值及同仓托盘快照。") @PostMapping("/weight-results") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") public ApiResponse createWeightResult( @@ -173,7 +189,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT", resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重") @Operation(summary = "确认 OCR 重量", - description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。") + description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后," + + "按所有已确认结果分别重算入库单毛重与扣皮后的货物净重。") @PostMapping("/weight-results/{resultId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')" + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index 5faba346d..c96ac1791 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -6,13 +6,12 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.service.MeasurementReviewService; -import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.InitialInboundPricingService; import com.baiyue.firstleg.service.FirstlegScopeGuard; -import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -36,15 +35,15 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/inbounds") public class PlatformInboundMeasurementReviewController { @Resource private MeasurementReviewService service; - @Resource private AutomaticInboundCompletionService automaticCompletion; + @Resource private InitialInboundPricingService initialPricing; @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量审核通过") @Operation(summary = "测量审核通过", - description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。") + description = "客服确认测量数据后直接进入待分柜,并按已审核测量数据尝试生成初算费用;初算失败不阻塞状态流转。") @PostMapping("/{inboundId}/measurements/approve") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") - public ApiResponse approve( + public ApiResponse approve( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, @@ -52,47 +51,9 @@ public class PlatformInboundMeasurementReviewController { @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody MeasurementReviewRequest request) { validateScope(user, tenantId); - // 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING, - // 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误, - // 使用同一幂等键重试会从持久化阶段继续推进。 service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(automaticCompletion.completeApproved( - tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1)); - } - - @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", - resourceId = "#inboundId", description = "测量审核驳回") - @Operation(summary = "测量审核驳回", - description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。") - @PostMapping("/{inboundId}/measurements/reject") - @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") - public ApiResponse reject( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, - @PathVariable long inboundId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody MeasurementReviewRequest request) { - validateScope(user, tenantId); - service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(null); - } - - @BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND", - resourceId = "#inboundId", description = "测量驳回异常放行") - @Operation(summary = "测量驳回异常放行", - description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。") - @PostMapping("/{inboundId}/measurements/exception-release") - @PreAuthorize("hasAuthority('firstleg:inbound:exception-release')") - public ApiResponse exceptionRelease( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, - @PathVariable long inboundId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody ExceptionReleaseRequest request) { - validateScope(user, tenantId); - service.exceptionRelease( - tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(null); + return ApiResponse.success(initialPricing.tryCalculate(tenantId, user.userId(), inboundId, + request.measurementId(), request.expectedVersion() + 1)); } @Operation(summary = "查询测量审核历史") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index d9fc87a4c..153ae3d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -9,6 +9,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; @@ -66,7 +68,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.preloadDetail(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") + @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( @@ -80,6 +82,19 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } + @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") + @PostMapping("/loading-plans/{planId}/confirm") + @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + public ApiResponse confirmPreload( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ConfirmPreloadRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmPreload( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @GetMapping("/loading-lists") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") @@ -129,8 +144,8 @@ public class PlatformLoadingListController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, @RequestParam @Positive long warehouseId, @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(service.uploadLoadingDeclaration(planId, warehouseId, user.userId(), user.username(), key, expectedVersion, operationReason, file)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 872bbfdf1..269c7c457 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -45,12 +45,11 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String containerNo, @RequestParam(required = false) String cabinetNo, @RequestParam(required = false) Long loadingPlanId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId)); + return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId)); } @GetMapping("/tasks/pending") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index d6c02bf72..0560e482f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -22,6 +22,8 @@ public class PlatformSmartLoadingController { public ApiResponse recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} + @PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + public ApiResponse warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));} @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java index 7a5f6ec23..8dfc3ee84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -1,12 +1,12 @@ package com.baiyue.firstleg.controller; -import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; +import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service; @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));} @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));} @GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));} @GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));} @PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));} - @PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));} + @PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam @NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));} @DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);} @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:create')") @BusinessAudit(event="TRUCK_APPOINTMENT_CREATE",resourceType="TRUCK_APPOINTMENT",captureBefore=false) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(u.userId(),u.username(),r));} @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse edit(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.edit(id,u.userId(),u.username(),r));} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java index 50e22037f..0c208dc1e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java @@ -29,7 +29,7 @@ public interface CustomerTransitStatisticsMapper { JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id WHERE c.actual_sailing_at IS NOT NULL AND c.actual_warehouse_arrival_at IS NULL - AND b.status NOT IN ('CANCELLED', 'FAILED') + AND b.status <> 'CANCELLED' AND NOT EXISTS ( SELECT 1 FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id @@ -43,7 +43,7 @@ public interface CustomerTransitStatisticsMapper { JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 WHERE c.actual_sailing_at IS NOT NULL AND c.actual_warehouse_arrival_at IS NULL - AND b.status NOT IN ('CANCELLED', 'FAILED') + AND b.status <> 'CANCELLED' AND i.account_id IS NOT NULL AND i.account_id > 0 ) x WHERE x.customer_id = #{customerId} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java index 494a9c8b7..7efb53113 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java @@ -51,7 +51,8 @@ public final class CustomsDtos { LocalDateTime supplementDueAt, @Min(0) int expectedVersion) {} - public record SubmitBatchRequest(@NotBlank @Size(max = 128) String customsAgentName, + public record SubmitBatchRequest(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId, + @Size(max = 128) String customsAgentName, @Size(max = 128) String externalReference, @Size(max = 500) String note, @Min(0) int expectedVersion) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java index 537f12c9b..d68fa1c84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java @@ -11,6 +11,7 @@ public class CustomsSubmissionBatchEntity { private Long customsCaseId; private String batchNo; private Integer batchVersion; + private Long customsAgentId; private String customsAgentName; private String externalReference; private String fileSnapshotJson; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java index bbdcfa067..d13bae634 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.customs.client.CustomsFileClient; import com.baiyue.firstleg.customs.dto.CustomsDtos.*; import com.baiyue.firstleg.customs.entity.*; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -36,6 +37,7 @@ public class CustomsService { @Resource private CustomsRepository repository; @Resource private CustomsFileClient files; @Resource private ObjectMapper objectMapper; + @Resource private WarehouseSupplierSnapshotService suppliers; @Value("${baiyue.security.internal-service-token}") private String internalToken; public PageResult page(Query query) { return repository.page(query); } @@ -177,7 +179,10 @@ public class CustomsService { } CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity(); batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1); - batch.setBatchNo("CB" + batch.getId()); batch.setCustomsAgentName(request.customsAgentName().trim()); + batch.setBatchNo("CB" + batch.getId()); + var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName()); + batch.setCustomsAgentId(customsAgent == null ? null : customsAgent.supplierId()); + batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name()); batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream() .map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(), "documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java new file mode 100644 index 000000000..c5d101c7d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java @@ -0,0 +1,43 @@ +package com.baiyue.firstleg.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.math.BigDecimal; +import java.util.Locale; +import java.util.Map; + +/** Platform-controlled container specifications shared by booking and simulation flows. */ +public final class ContainerSpecificationCatalog { + private static final Map VALUES = Map.of( + "20GP", new Specification("20GP", new BigDecimal("33"), new BigDecimal("28000")), + "40GP", new Specification("40GP", new BigDecimal("67"), new BigDecimal("28000")), + "40HC", new Specification("40HC", new BigDecimal("76"), new BigDecimal("28000")), + "40NR", new Specification("40NR", new BigDecimal("67"), new BigDecimal("28000")), + "45HC", new Specification("45HC", new BigDecimal("86"), new BigDecimal("28000")) + ); + + private ContainerSpecificationCatalog() {} + + public static Specification require(String containerType) { + String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); + Specification specification = VALUES.get(normalized); + if (specification == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "柜型必须使用平台固定规格"); + } + return specification; + } + + public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { + Specification specification = require(containerType); + if (capacityCbm == null || maxWeightKg == null + || specification.capacityCbm().compareTo(capacityCbm) != 0 + || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "柜型容量和限重必须使用平台固定规格"); + } + return specification; + } + + public record Specification(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java index 6c14bd1ac..0766bf4f9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java @@ -8,29 +8,33 @@ public final class PriceBookChannelCatalog { private PriceBookChannelCatalog() {} public static final List CHANNELS = List.of( - fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"), - fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"), - fba("EXX_TRUCK", "EXX海卡", "EXX海卡"), - fba("HEDE_TRUCK", "合德海卡", "合德海卡"), - fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"), - fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"), - fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"), - fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"), - fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"), - fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"), - fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"), - parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"), - parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"), - parcel("HEDE_PARCEL", "合德海派", "海派"), - parcel("OA_PARCEL", "普船海派", "海派") + fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"), + fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"), + fba("EXX_TRUCK", "EXX海卡", "EXX海卡", "EXXHK", "EXXHWK"), + fba("HEDE_TRUCK", "合德海卡", "合德海卡", "HDHK", "HDHWK"), + fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁", "NYEXX", null), + fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡", "PCHK", "PCHWK"), + fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡", "PCKK", null), + fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁", "CPHT", null), + fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡", "HPHK", null), + fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡", "NPHK", "NPHWK"), + fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡", "SPHK", null), + parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派", "ZBYHP", null), + parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派", "BYHP", null), + parcel("HEDE_PARCEL", "合德海派", "海派", "HDHP", null), + parcel("OA_PARCEL", "普船海派", "海派", "PCHP", null) ); - private static ChannelDefinition fba(String code, String name, String sheet) { - return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name)); + private static ChannelDefinition fba(String code, String name, String sheet, + String inboundCodePrefix, String overseasInboundCodePrefix) { + return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name), + inboundCodePrefix, overseasInboundCodePrefix); } - private static ChannelDefinition parcel(String code, String name, String sheet) { - return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name)); + private static ChannelDefinition parcel(String code, String name, String sheet, + String inboundCodePrefix, String overseasInboundCodePrefix) { + return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name), + inboundCodePrefix, overseasInboundCodePrefix); } public record ChannelDefinition( @@ -38,6 +42,8 @@ public final class PriceBookChannelCatalog { String displayName, PriceModel priceModel, String sourceSheetName, - Set aliases + Set aliases, + String inboundCodePrefix, + String overseasInboundCodePrefix ) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java index 7cc75ad55..dd3222420 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java @@ -11,7 +11,6 @@ public record CartonMeasurementCommand( BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, - BigDecimal unitWeightKg, String machineNo, LocalDateTime measuredAt, MultipartFile image, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java index 34997f196..36c281d32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java @@ -14,7 +14,6 @@ public record CartonMeasurementResult( BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, - BigDecimal unitWeightKg, Long imageFileId, String machineNo, LocalDateTime measuredAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 39b899631..f18cd0fb2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -25,6 +25,7 @@ public final class ContainerDtos { @Size(max = 64) String cabinetNo, @Size(max = 128) String billOfLadingNo, @Size(max = 128) String keyword, + @Positive Long customerId, @Positive Long warehouseId, @Size(max = 32) String containerType, @Positive Long shippingCompanyId, @@ -46,7 +47,6 @@ public final class ContainerDtos { @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") public record SaveRequest( @Size(max = 64) String containerNo, - @NotBlank @Size(max = 64) String cabinetNo, @NotNull @Positive Long warehouseId, @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @@ -63,6 +63,7 @@ public final class ContainerDtos { @Size(max = 128) String destinationWarehouseName, @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Positive Long terminalTruckingCompanyId, @Size(max = 128) String terminalTruckingCompany, LocalDateTime loadingAt, LocalDateTime estimatedSailingAt, @@ -91,4 +92,19 @@ public final class ContainerDtos { this(containerNo, voyageNo, version, "运输信息更新"); } } + + @Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成") + public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode, + @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {} + + @Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号") + public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId, + @NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo, + @NotNull @PositiveOrZero Integer expectedVersion) {} + + @Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联") + public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @NotBlank @Size(max = 500) String reason) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java index bdef4e629..97d07a58e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -20,29 +20,16 @@ public final class ContainerFeeDetailDtos { @NotNull LocalDate businessDate, @NotNull @DecimalMin("0.00") BigDecimal amount, @NotBlank @Pattern(regexp = "CNY") String currency, - @NotNull Boolean settlementIncluded, - Long partnerId, - @Size(max = 200) String partnerName, + Long supplierId, @Size(max = 500) String remark ) { } public record AdjustRequest( @NotNull @Min(1) Integer expectedSourceVersion, - @NotNull @Min(1) Long feeItemId, - @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, @NotNull LocalDate businessDate, - @NotNull @DecimalMin("0.00") BigDecimal amount, - @NotBlank @Pattern(regexp = "CNY") String currency, - @NotNull Boolean settlementIncluded, - Long partnerId, - @Size(max = 200) String partnerName, - @Size(max = 500) String remark + @NotNull @DecimalMin("0.00") BigDecimal amount ) { - public SaveRequest values() { - return new SaveRequest(feeItemId, direction, businessDate, amount, currency, - settlementIncluded, partnerId, partnerName, remark); - } } public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 623a50887..8abce64c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -16,7 +16,7 @@ public final class ContainerOverviewDtos { String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, - Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, + String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, List photoCounts, List photos, List exceptionReasons) {} public record PhotoCount(String evidenceType, Integer count) {} public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java new file mode 100644 index 000000000..3e571b147 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; + +/** 当前业务单元所关联柜次的正式运输事实。 */ +public record CurrentContainerTransport( + Long inboundId, Long accountId, Long containerBookingId, + String containerNo, String cabinetNo, String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt +) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index b425c98b3..dbec6b7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -52,7 +53,9 @@ public final class FbaPricingDtos { public record CalculateRequest(LocalDateTime pricingAt) {} public record AdjustFeeRequest( - @NotNull @DecimalMin("0.00") BigDecimal subtotalAmount, + @NotNull @Positive Integer expectedFeeVersion, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount, @NotBlank @Size(max = 500) String reason ) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java index e5f608dbb..019125cae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java @@ -14,6 +14,7 @@ public final class FeeItemDtos { @NotBlank @Size(max = 64) String category, @NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope, @Size(max = 50) @Pattern(regexp = "[A-Za-z0-9_-]{1,50}") String feeCode, + @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String supplierCategory, @NotBlank @Size(max = 128) String feeTypeName, @NotNull Boolean pickupPayable, @NotNull Boolean pickupReceivable, @@ -22,12 +23,22 @@ public final class FeeItemDtos { @NotNull Boolean allocationFee, @Size(max = 500) String description ) { + public SaveRequest(String category, String tradeScope, String feeCode, + String feeTypeName, Boolean pickupPayable, + Boolean pickupReceivable, Boolean warehousePayable, + Boolean warehouseReceivable, Boolean allocationFee, + String description) { + this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable, + pickupReceivable, warehousePayable, warehouseReceivable, + allocationFee, description); + } + /** Backward-compatible constructor for callers predating the stable fee code. */ public SaveRequest(String category, String tradeScope, String feeTypeName, Boolean pickupPayable, Boolean pickupReceivable, Boolean warehousePayable, Boolean warehouseReceivable, Boolean allocationFee, String description) { - this(category, tradeScope, null, feeTypeName, pickupPayable, pickupReceivable, + this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable, warehousePayable, warehouseReceivable, allocationFee, description); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java index 18ffe81a5..cf27da492 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java @@ -18,8 +18,9 @@ public record FinanceFeeSourceSnapshot( String containerNo, long customerId, String customerName, - Long partnerId, - String partnerName, + Long supplierId, + String supplierName, + String supplierCategory, Long feeItemId, String feeItemCode, String feeItemName, @@ -29,7 +30,6 @@ public record FinanceFeeSourceSnapshot( BigDecimal totalAmount, String reviewStatus, String publicationStatus, - boolean settlementIncluded, LocalDateTime publishedAt, BigDecimal chargeableQuantity, String chargeUnit, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java index 4cbf5d28b..f74f31b23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java @@ -21,15 +21,13 @@ public final class InboundFeeDetailDtos { @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, @NotNull @DecimalMin("0.00") BigDecimal amount, - @NotNull Boolean settlementIncluded, - Long partnerId, - @Size(max = 200) String partnerName, + Long supplierId, @Size(max = 500) String remark ) { public SaveRequest(Long feeItemId, String direction, LocalDate businessDate, BigDecimal unitPrice, String currency, BigDecimal amount, String remark) { - this(feeItemId, direction, businessDate, unitPrice, currency, amount, false, null, null, remark); + this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 38bebfe18..260ac1924 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.dto; +import com.fasterxml.jackson.annotation.JsonAnySetter; import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMax; @@ -26,12 +27,13 @@ public final class InboundMeasurementRequests { public record SaveDraftRequest( Long measurementId, @NotNull @PositiveOrZero Integer version, - @Size(max = 64) String originalDocumentNo, - @DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg, - @DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm, @Size(max = 255) String actualGoodsName, @NotEmpty @Size(max = 500) List<@Valid ItemRequest> items ) { + @JsonAnySetter + public void rejectUnknownField(String fieldName, Object ignored) { + throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName); + } } public record ItemRequest( @@ -42,7 +44,6 @@ public final class InboundMeasurementRequests { @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm, - @NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg, @Positive Long intelligentImageFileId, @Size(max = 500) String overLimitWarning, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, @@ -70,7 +71,6 @@ public final class InboundMeasurementRequests { @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height, - @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight, @Size(max = 64) String scannedShipmentId, @NotBlank @Size(max = 64) String machineNo, @NotNull LocalDateTime measuredAt @@ -79,6 +79,7 @@ public final class InboundMeasurementRequests { public record CreateWeightResultRequest( @NotNull @Positive Long fileId, + @NotNull @Positive Long palletId, @Size(max = 64) String ocrProvider, @Size(max = 1000) String ocrRawText, @DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 67744b939..6ea5847c8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -40,6 +40,7 @@ public final class InboundMeasurementResponses { Integer originalPackageCount, Integer actualPackageCount, BigDecimal actualGrossWeightKg, + BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName, @@ -58,9 +59,7 @@ public final class InboundMeasurementResponses { BigDecimal widthCm, BigDecimal heightCm, BigDecimal girthCm, - BigDecimal unitWeightKg, BigDecimal unitVolumeCbm, - BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal volumeWeightKg, Long intelligentImageFileId, @@ -81,14 +80,20 @@ public final class InboundMeasurementResponses { public record WeightResult( Long id, Long fileId, + Long palletId, + String palletCode, + String palletType, + BigDecimal palletCapacityCbm, + BigDecimal palletTareWeightKg, String ocrProvider, String ocrRawText, BigDecimal ocrWeightValue, String ocrWeightUnit, BigDecimal confidence, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, + BigDecimal netWeightKg, String recognitionStatus, String adjustmentReason, Long adjustedBy, @@ -99,6 +104,15 @@ public final class InboundMeasurementResponses { ) { } + public record PalletSnapshot( + Long palletId, + String palletCode, + String palletType, + BigDecimal capacityCbm, + BigDecimal tareWeightKg + ) { + } + public record DraftSaved(Long measurementId, Integer version, String status) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f68fc7bc5..f5a98f09a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.dto; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -12,7 +13,7 @@ public record InboundOrderSummary( String processStatus, String orderStatusSnapshot, String measureReviewStatus, String feeStatus, String feeReviewStatus, String notificationStatus, Integer version, - LocalDateTime expectedArrivalAt, + LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, Long customerId, String customerName, Long companyId, String companyName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, @@ -26,18 +27,33 @@ public record InboundOrderSummary( String locationCodes, LocalDateTime measureCompletedAt, LocalDateTime inboundedAt, String expressCompanyCode, String expressCompanyName, String expressTrackingNo, - String loadingStatus, String containerNo, String cabinetNo, - String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt, + String containerNo, String cabinetNo, + String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { + /** 非统一入仓接口保留既有快照适配;统一订单不得调用此重载。 */ public static InboundOrderSummary from( - InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) { + InboundOrderEntity order, InboundTransportSnapshotEntity transport, + InboundPickupSnapshotEntity pickup, String locationCodes) { + CurrentContainerTransport adapted = transport == null ? null : new CurrentContainerTransport( + order.getId(), order.getTenantId(), null, transport.getContainerNo(), transport.getCabinetNo(), + transport.getVesselName(), transport.getVoyageNo(), transport.getSailingAt(), null, + transport.getPortArrivalAt(), null); + return from(order, adapted, pickup, locationCodes); + } + + public static InboundOrderSummary from( + InboundOrderEntity order, CurrentContainerTransport transport, + InboundPickupSnapshotEntity pickup, String locationCodes) { return new InboundOrderSummary( order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(), order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(), order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(), order.getVersion(), - order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(), + order.getExpectedArrivalAt(), pickup == null ? null : pickup.getPickupAt(), + order.getCustomerId(), order.getCustomerName(), order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(), order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(), order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(), @@ -50,13 +66,14 @@ public record InboundOrderSummary( locationCodes, order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(), order.getExpressCompanyName(), order.getExpressTrackingNo(), - transport == null ? null : transport.getLoadingStatus(), - transport == null ? null : transport.getContainerNo(), - transport == null ? null : transport.getCabinetNo(), - transport == null ? null : transport.getVesselName(), - transport == null ? null : transport.getVoyageNo(), - transport == null ? null : transport.getSailingAt(), - transport == null ? null : transport.getPortArrivalAt(), + transport == null ? null : transport.containerNo(), + transport == null ? null : transport.cabinetNo(), + transport == null ? null : transport.vesselName(), + transport == null ? null : transport.voyageNo(), + transport == null ? null : transport.estimatedSailingAt(), + transport == null ? null : transport.actualSailingAt(), + transport == null ? null : transport.estimatedPortArrivalAt(), + transport == null ? null : transport.actualPortArrivalAt(), order.getCreatedAt(), order.getUpdatedAt() ); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index f72262371..d7b45a413 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -18,6 +18,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @@ -32,6 +33,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @@ -81,12 +83,43 @@ public final class LoadingListDtos { public record Customer(Long accountId, String customerName) { } + public record PreloadReadiness( + boolean preloadPushed, + boolean containerNoReady, + int preloadLinkCount, + int confirmedPreloadCount, + Long oceanBookingId, + String oceanBookingStatus, + Long pdaTaskId, + String pdaTaskStatus, + String confirmationStatus, + boolean canConfirm, + boolean readyForPda, + List blockingReasons + ) { + public PreloadReadiness { + blockingReasons = blockingReasons == null ? List.of() : List.copyOf(blockingReasons); + } + } + + public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) { + } + + public record PreloadConfirmationResponse( + Long planId, + Long containerBookingId, + String confirmationStatus, + Integer version, + PreloadReadiness readiness + ) { + } + public record PreloadSummary( Long planId, Long containerBookingId, String preloadNo, String containerNo, - String internalContainerNo, + String cabinetNo, List customers, Long warehouseId, String warehouseName, @@ -99,7 +132,8 @@ public final class LoadingListDtos { BigDecimal totalVolumeCbm, BigDecimal totalGrossWeightKg, LocalDateTime createdAt, - Integer version + Integer version, + PreloadReadiness readiness ) { public PreloadSummary { customers = customers == null ? List.of() : List.copyOf(customers); @@ -113,7 +147,7 @@ public final class LoadingListDtos { Long warehouseId, String warehouseName, String containerNo, - String internalContainerNo, + String cabinetNo, String status, long totalPackageCount, BigDecimal totalGrossWeightKg, @@ -132,9 +166,12 @@ public final class LoadingListDtos { Long itemId, Long accountId, String customerName, + Long orderId, + String orderNo, Long inboundId, Long inboundDetailId, String inboundCode, + String containerNo, String skuCode, String destinationWarehouse, String chineseName, @@ -155,7 +192,8 @@ public final class LoadingListDtos { String customerRemark, String warehouseRemark, int loadSequence, - LocalDateTime frozenAt + LocalDateTime frozenAt, + Integer itemVersion ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index f08139693..2016fbc7d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -60,17 +60,10 @@ public final class LoadingPickDtos { @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long loadingPlanId, @NotNull @Min(1) Long containerBookingId, - @Size(max = 64) String containerNo, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items - ) { - @Deprecated - public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion, - String inventorySnapshotToken, List items) { - this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items); - } - } + ) {} public record BatchItemRequest( @NotNull @Min(1) Long itemId, @@ -132,8 +125,10 @@ public final class LoadingPickDtos { Long operatorId, String operatorName, LocalDateTime createdAt) { } - public record Task(Long id, Long loadingPlanId, Long containerBookingId, String cabinetNo, - String currentContainerNo, String taskStatus, + public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId, + Integer inboundVersion, Integer loadingVersion, String cabinetNo, + String currentContainerNo, String sourceType, String recordStatus, + Long mergedIntoContainerBookingId, String taskStatus, String workflowStatus, Integer version, Assignment assignment, Review review, List items) { } @@ -141,6 +136,7 @@ public final class LoadingPickDtos { /** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */ public record PendingTask(Long taskId, Long loadingPlanId, Long containerBookingId, String cabinetNo, String currentContainerNo, + String sourceType, String recordStatus, Long mergedIntoContainerBookingId, Long warehouseId, String warehouseName, String taskStatus, String workflowStatus, Integer version, Integer assignmentVersion, Integer itemCount, Integer pendingItemCount) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java new file mode 100644 index 000000000..84a4da9b9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java @@ -0,0 +1,13 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; + +public record MeasurementApprovalResponse( + Long inboundId, + Long measurementId, + String processStatus, + String feeStatus, + boolean initialPricingSucceeded, + String initialPricingMessage, + FeeSnapshotResponse initialFee +) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java index 3c3ed8dd1..653d4c315 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java @@ -35,7 +35,6 @@ public record MeasurementBatchCommand( BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, - BigDecimal unitWeightKg, Long intelligentImageFileId, String overLimitWarning, BigDecimal outerLengthCm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index 39544286f..b884526ed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -19,7 +19,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, public record MeasurementSummary( Long measurementId, String sourceType, String measurementStatus, - Integer actualPackageCount, BigDecimal actualGrossWeightKg, + Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) { } @@ -31,15 +31,16 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, public record MeasurementItem( Long id, String itemNo, String boxSpecification, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, - BigDecimal unitWeightKg, BigDecimal totalWeightKg, BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm, BigDecimal volumeWeightKg) { } public record WeightResult( - Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit, - BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, String recognitionStatus) { + Long id, Long fileId, Long palletId, String palletCode, String palletType, + BigDecimal palletCapacityCbm, BigDecimal palletTareWeightKg, + BigDecimal recognizedWeightValue, String recognizedWeightUnit, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, + BigDecimal netWeightKg, String recognitionStatus) { } public record BillingBasis( @@ -62,7 +63,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, BigDecimal chargeableQuantity, String chargeUnit, String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount, String reviewStatus, String publicationStatus, - LocalDateTime publishedAt, LocalDateTime inboundedAt, Boolean settlementIncluded + LocalDateTime publishedAt, LocalDateTime inboundedAt ) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java index 96e61a581..a64dc7948 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java @@ -23,7 +23,6 @@ public final class MojiaMeasurementDtos { @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height, - @DecimalMin("0.001") @DecimalMax("999999999999999.999") BigDecimal weight, @NotBlank @Size(max = 64) String machineNo, LocalDateTime measuredAt, @NotBlank @Size(max = 7_100_000) String picture diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java index 249b89084..3b8277a63 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java @@ -47,7 +47,6 @@ public final class OpenApiMeasurementDtos { @NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm, @NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm, @NotNull @Min(1) @Max(1000000) Integer packageCount, - @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg, @NotBlank @Size(max = 64) String boxSpecification, @Size(max = 500) String remark) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index e9835b181..92665d4bd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -31,8 +31,25 @@ public record OrderApprovedInboundCommand( String publicRemark, String expressCompanyName, String expressTrackingNo, - Integer totalPackageCount + Integer totalPackageCount, + String pickupMethod, + LocalDateTime requestedPickupAt ) { + public OrderApprovedInboundCommand( + long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, + Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, + LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode, + Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, + String destinationName, String publicRemark, String expressCompanyName, String expressTrackingNo, + Integer totalPackageCount + ) { + this(tenantId, orderId, orderNo, inboundCode, customerId, customerName, warehouseId, warehouseName, + deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, + carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, + destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, + expressTrackingNo, totalPackageCount, null, null); + } /** 兼容尚未携带订单总箱数的历史事件。 */ public OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, @@ -46,7 +63,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null); + expressTrackingNo, null, null, null); } /** 兼容未携带报价包税维度的历史调用方。 */ @@ -62,6 +79,6 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null); + expressTrackingNo, null, null, null); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java index 10c337bc9..dccef333b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; public final class PriceBookChannelValidation { private PriceBookChannelValidation() {} @@ -10,6 +11,10 @@ public final class PriceBookChannelValidation { public record Request( @Min(1) long tenantId, @Min(1) long originWarehouseId, - @NotBlank @Size(max = 64) String channelCode + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, + boolean requireInboundCodePrefix ) {} + + public record Response(String channelCode, String inboundCodePrefix) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 9b5474500..dba00e1f0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -3,17 +3,17 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validati public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, - BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount, - BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { - this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { + this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm); } public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, - String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,destinationWarehouseId, - destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm); } } public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} @@ -25,6 +25,11 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} + public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark, + @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, + @NotBlank @Size(max=128) String operationId) {} public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {} - public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List items) {} + public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo, + Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus, + Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List items) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index a51c8ab17..a0a6cef2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} - public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} - public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } + public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 85ac199e2..0d1ec3a42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -20,10 +20,15 @@ public class ContainerBookingEntity { private String bookingNo; private String containerNo; private String cabinetNo; + private String sourceType; + private String recordStatus; + private Long mergedIntoContainerBookingId; private String channelCode; private String channelName; private Long oceanBookingId; private Long oceanBookingRequirementId; + private Long customerId; + private String customerName; private Long warehouseId; private String warehouseName; private String billOfLadingNo; @@ -40,6 +45,7 @@ public class ContainerBookingEntity { private String destinationWarehouseName; private BigDecimal capacityCbm; private BigDecimal maxWeightKg; + private Long terminalTruckingCompanyId; private String terminalTruckingCompany; private LocalDateTime loadingAt; private LocalDateTime estimatedSailingAt; @@ -56,5 +62,11 @@ public class ContainerBookingEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; @Version @TableField(fill = FieldFill.INSERT) private Integer version; @TableField(exist = false) private Long planId; + @TableField(exist = false) private String planNo; @TableField(exist = false) private String planStatus; + @TableField(exist = false) private Boolean exceptionActive; + @TableField(exist = false) private String destinationClearanceDocumentStatus; + @TableField(exist = false) private String customsDeclarationDocumentStatus; + @TableField(exist = false) private String deliveryOrderDocumentStatus; + @TableField(exist = false) private String pickupOrderDocumentStatus; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java index 5a61d5f9d..6f6c4c8c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java @@ -25,12 +25,12 @@ public class ContainerFeeDetailEntity { private String feeItemCodeSnapshot; private String feeItemNameSnapshot; private String direction; - private Long partnerId; - private String partnerName; + private Long supplierId; + private String supplierName; + private String supplierCategory; private LocalDate businessDate; private BigDecimal amount; private String currency; - private Boolean settlementIncluded; private String reviewStatus; private String sourceStatus; private Integer sourceVersion; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java new file mode 100644 index 000000000..3a08d0b43 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 柜次费用审核发布时固化的不可变版本快照。 */ +@Getter +@Setter +@TableName("fl_container_fee_source_snapshot") +public class ContainerFeeSourceSnapshotEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long sourceFeeId; + private Integer sourceVersion; + private Long tenantId; + private Long loadingPlanId; + private Long containerBookingId; + private String containerNo; + private Long feeItemId; + private String feeItemCode; + private String feeItemName; + private String direction; + private Long supplierId; + private String supplierName; + private String supplierCategory; + private LocalDate businessDate; + private BigDecimal amount; + private String currency; + private String reviewStatus; + private LocalDateTime reviewedAt; + private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java index 6a62b0bf6..2cc751bb3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java @@ -18,6 +18,7 @@ public class FeeItemEntity extends BaseEntity { private String category; private String tradeScope; private String feeCode; + private String supplierCategory; private String feeTypeName; private Boolean pickupPayable; private Boolean pickupReceivable; @@ -28,4 +29,5 @@ public class FeeItemEntity extends BaseEntity { private String status; private String createdByName; private String updatedByName; + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java index f54a2cc98..02f9d1b84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java @@ -27,8 +27,9 @@ public class InboundFeeDetailEntity extends BaseEntity { private String sourceFeeNo; private String inboundCode; private String direction; - private Long partnerId; - private String partnerName; + private Long supplierId; + private String supplierName; + private String supplierCategory; private LocalDate businessDate; private Long customerId; private String customerName; @@ -62,5 +63,4 @@ public class InboundFeeDetailEntity extends BaseEntity { private String publishedByName; private LocalDateTime publishedAt; private LocalDateTime inboundedAt; - private Boolean settlementIncluded; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index 5cd87dcf4..c012a9f85 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -29,11 +29,9 @@ public class InboundMeasurementItemEntity { private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal girthCm; - private BigDecimal unitWeightKg; private BigDecimal unitVolumeCbm; private BigDecimal volumeRawCbm; private BigDecimal volumeCbm; - private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; private BigDecimal volumeWeightKg; private String calculationRuleVersion; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPickupSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPickupSnapshotEntity.java new file mode 100644 index 000000000..3d09b7896 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPickupSnapshotEntity.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 客户上门提货要求在入库域的快照。 */ +@Getter +@Setter +@TableName("fl_inbound_pickup_snapshot") +public class InboundPickupSnapshotEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private String pickupMethod; + private LocalDateTime pickupAt; + private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java index 3796af551..a8429d117 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -36,6 +36,7 @@ public class InboundPricingEntity extends BaseEntity { private Long destinationWarehouseId; private String destinationName; private BigDecimal actualGrossWeightKg; + private BigDecimal actualNetWeightKg; private BigDecimal actualVolumeCbm; private BigDecimal chargeableWeightKg; private BigDecimal chargeableVolumeCbm; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java index 6e00d2919..e981ba446 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java @@ -10,7 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; -/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */ +/** 称重图片、托盘皮重快照、OCR 原值和最终确认净重。 */ @Getter @Setter @TableName("fl_inbound_weight_result") @@ -20,6 +20,11 @@ public class InboundWeightResultEntity extends BaseEntity { private Long tenantId; private Long inboundId; private Long fileId; + private Long palletId; + private String palletCode; + private String palletType; + private BigDecimal palletCapacityCbm; + private BigDecimal palletTareWeightKg; private String ocrProvider; private String ocrRawText; private BigDecimal ocrWeightValue; @@ -27,7 +32,8 @@ public class InboundWeightResultEntity extends BaseEntity { private BigDecimal confidence; private BigDecimal adjustedWeightValue; private String adjustedWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String recognitionStatus; private String adjustmentReason; private Long adjustedBy; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java index 7882ebdef..ef6eb203b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java @@ -25,7 +25,8 @@ public class InboundWeightRevisionEntity { private String beforeWeightUnit; private BigDecimal afterWeightValue; private String afterWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String reason; private Long operatorId; private String operatorName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java index 957eb1b9e..6e402cecf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -10,6 +10,11 @@ public class LoadingPlanItemEntity { private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted; - @TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt; + @TableField(exist=false) private Long activeInboundDetailId; + @TableField(exist=false) private Integer pickedPackageCount; + @TableField(exist=false) private Integer minPackageCount; + @TableField(exist=false) private Boolean canAdjust; + @TableField(exist=false) private Boolean canRemove; + private Long createdBy; private String createdByName; private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java index cae614d9c..ff341cc93 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java @@ -23,7 +23,6 @@ public class MojiaMeasurementReceiptEntity { private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; - private BigDecimal weightKg; private String machineNo; private LocalDateTime measuredAt; private Long imageFileId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java index 813ad1063..dba5884f6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java @@ -15,5 +15,7 @@ public class PriceChannelCatalogEntity { private String priceModel; private String sourceSheetName; private String aliasesJson; + private String inboundCodePrefix; + private String overseasInboundCodePrefix; private String status; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index c8afd1f65..6f845f881 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableField; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; @@ -22,6 +23,7 @@ public class TruckAppointmentEntity { private String customerName; private Long carrierId; private String carrierName; + private String billOfLadingNo; private String fleetContact; private String fleetPhone; private String driverName; @@ -53,4 +55,13 @@ public class TruckAppointmentEntity { private String remark; private LocalDateTime createdAt; private LocalDateTime updatedAt; + @TableField(exist = false) private String oceanBookingNo; + @TableField(exist = false) private String warehouseName; + @TableField(exist = false) private Long shippingCompanyId; + @TableField(exist = false) private String shippingCompanyName; + @TableField(exist = false) private Long bookingAgentId; + @TableField(exist = false) private String bookingAgentName; + @TableField(exist = false) private String cabinetNo; + @TableField(exist = false) private String containerNo; + @TableField(exist = false) private Long attachmentCount; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index a24748e68..61f5b0ae3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper0") + List allocatedScopes(@Param("loadingPlanId") long loadingPlanId); + @Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}," + "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} " + "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}") @@ -34,6 +39,18 @@ public interface LoadingModelBridgeMapper extends BaseMapper allocatedScopes(long loadingPlanId) { return mapper.allocatedScopes(loadingPlanId); } public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) { return mapper.selectOne(new LambdaQueryWrapper() @@ -40,11 +42,19 @@ public class LoadingModelBridgeRepository { public void insert(PreloadLoadingPlanLinkEntity value) { mapper.insert(value); } + public void delete(PreloadLoadingPlanLinkEntity value) { mapper.deleteById(value); } + public int touchPreload(long preloadPlanId, long tenantId, long inboundId, int version, long operatorId, String operatorName) { return mapper.touchPreload(preloadPlanId, tenantId, inboundId, version, operatorId, operatorName); } + public int confirmIncrementalPreload(long preloadPlanId, long tenantId, long inboundId, int version, + long operatorId, String operatorName) { + return mapper.confirmIncrementalPreload(preloadPlanId, tenantId, inboundId, version, + operatorId, operatorName); + } + public int touchLoadingPlan(long loadingPlanId, int version, long operatorId, String operatorName) { return mapper.touchLoadingPlan(loadingPlanId, version, operatorId, operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java index f86fd38f0..634cfda73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -68,9 +69,117 @@ public class LoadingModelBridgeService { return requireLink(tenantId, inboundId, loadingPlanId); } + /** + * Creates every preload link required by a pushed loading plan before PDA can start. + * The caller owns the surrounding push transaction, so a missing preload or stale + * version rolls back the push and cannot leave a LOADING task without a link. + */ + @Transactional + public List bindAllocatedBeforeLoading(long loadingPlanId, long operatorId, + String operatorName, String orchestrationKey) { + var loadingPlan = repository.loadingPlan(loadingPlanId); + if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + List scopes = repository.allocatedScopes(loadingPlanId); + if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业"); + if (!"EDITING".equals(loadingPlan.status())) { + if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status()) + && scopes.stream().allMatch(scope -> repository.byPair( + scope.tenantId(), scope.inboundId(), loadingPlanId) != null)) { + return repository.byLoadingPlan(loadingPlanId); + } + throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联"); + } + for (AllocationScope scope : scopes) { + // Internal push replay is pair-idempotent: an existing association has + // already advanced both versions, so calling public bind again would + // derive a different request hash for the same AUTO key. + if (repository.byPair(scope.tenantId(), scope.inboundId(), loadingPlanId) != null) { + continue; + } + var preload = repository.preload(scope.tenantId(), scope.inboundId()); + var currentPlan = repository.loadingPlan(loadingPlanId); + if (preload == null || currentPlan == null) { + throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); + } + String key = "AUTO:" + hash(orchestrationKey + "|" + scope.tenantId() + "|" + + scope.inboundId() + "|" + loadingPlanId).substring(0, 60); + bind(scope.tenantId(), scope.inboundId(), loadingPlanId, operatorId, operatorName, key, + new BindRequest(preload.version(), currentPlan.version())); + } + return repository.byLoadingPlan(loadingPlanId); + } + + /** Adds a newly allocated inbound to an already pushed EDITING/LOADING plan. */ + @Transactional + public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId, + long operatorId, String operatorName, String operationId) { + if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return; + var plan = repository.loadingPlan(loadingPlanId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + if (plan.preloadPushedAt() == null) return; + if (!Set.of("EDITING", "LOADING").contains(plan.status())) { + throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联"); + } + if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) <= 0) { + throw conflict("柜次计划尚未分配当前入库单货物"); + } + var preload = repository.preload(tenantId, inboundId); + if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); + if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) { + throw conflict("新增入库单预装状态不允许进入当前柜次"); + } + PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity(); + link.setTenantId(tenantId); link.setInboundId(inboundId); link.setPreloadPlanId(preload.id()); + link.setLoadingPlanId(loadingPlanId); + link.setIdempotencyKey("INCREMENTAL:" + hash(operationId + "|" + tenantId + "|" + inboundId + + "|" + loadingPlanId).substring(0, 52)); + link.setRequestHash(hash("INCREMENTAL|" + tenantId + "|" + inboundId + "|" + loadingPlanId)); + link.setCreatedBy(operatorId); link.setCreatedByName(operatorName); + try { + repository.insert(link); + } catch (DuplicateKeyException exception) { + if (repository.byPair(tenantId, inboundId, loadingPlanId) == null) { + throw conflict("新增入库单预装关联并发冲突,请刷新后重试"); + } + } + // A running PDA task has already passed plan-level confirmation. Adding an inbound is the + // explicit Admin approval for that new allocation, so its fresh PENDING preload joins the + // same confirmed plan atomically instead of leaving an unreachable pending aggregate. + if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status()) + && repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId, + preload.version(), operatorId, operatorName) != 1) { + throw conflict("新增入库单预装确认并发冲突,请刷新后重试"); + } + } + + /** Removes only a now-empty allocation link; picked-item protection is enforced by the PDA task lock. */ + @Transactional + public void removeIfUnallocated(long loadingPlanId, long tenantId, long inboundId) { + if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) > 0) return; + PreloadLoadingPlanLinkEntity link = repository.byPair(tenantId, inboundId, loadingPlanId); + if (link != null) repository.delete(link); + } + public List byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); } public List byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); } + public List requireAllocatedLinksForConfirmation(long loadingPlanId) { + var loadingPlan = repository.loadingPlan(loadingPlanId); + if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) { + throw conflict("仅已推送且仍为EDITING的预装计划可以确认"); + } + List scopes = repository.allocatedScopes(loadingPlanId); + if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); + List links = repository.byLoadingPlan(loadingPlanId); + boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link -> + Objects.equals(link.tenantId(), scope.tenantId()) + && Objects.equals(link.inboundId(), scope.inboundId()) + && link.allocatedPackageCount() != null && link.allocatedPackageCount() > 0)); + if (!complete) throw conflict("装柜计划的预装关联不完整"); + return links; + } + public void requireStarted(long tenantId, long inboundId) { List links = requireLinks(tenantId, inboundId); if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java index a2b06c672..9ecc38874 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -18,6 +18,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; /** 平台端装柜证据和国内装柜完成操作。 */ @@ -58,6 +59,16 @@ public class PlatformLoadingDeliveryController { return ApiResponse.success(service.evidence(tenantId, inboundId)); } + @Operation(summary = "PDA查看装柜证据照片") + @GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ResponseEntity pdaEvidenceContent( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @PathVariable long evidenceId) { + FirstlegScopeGuard.platform(user, tenantId); + return service.evidenceContent(tenantId, inboundId, evidenceId); + } + @Operation(summary = "PDA作废错误装柜证据") @PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java index df27a188b..c725b4205 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java @@ -7,7 +7,7 @@ public final class LoadingResponses { private LoadingResponses() {} public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType, - Long fileId, String fileName, String sealNo, String remark, + Long fileId, String fileName, String previewUrl, String sealNo, String remark, Integer version, String status, String voidReason, Long voidedBy, String voidedByName, LocalDateTime voidedAt, LocalDateTime createdAt, LocalDateTime updatedAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java index 9b71552f0..62e2ea610 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java @@ -12,20 +12,15 @@ public interface LoadingCompletionGateMapper { COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM fl_ocean_booking_container_allocation a JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id - WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady, - COALESCE(SUM(CASE WHEN EXISTS ( - SELECT 1 FROM fl_truck_appointment t - WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id - AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady + WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady FROM fl_preload_loading_plan_link l JOIN fl_loading_plan p ON p.id=l.loading_plan_id WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} """) GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady) { + record GateSnapshot(Integer linkCount, Integer oceanReady) { public boolean allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); } - public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); } private static int count(Integer value) { return value == null ? 0 : value; } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index 3359c09f3..39ef280cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -11,6 +11,16 @@ public interface LoadingInboundMapper { + "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version " + + "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 FOR UPDATE") + Snapshot findForUpdate(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " + + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " + + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") + int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + @Select(""" SELECT batch.source_inbound_id AS sourceInboundId, batch.loaded_child_inbound_id AS loadedChildInboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index cae31a46b..221f62e3d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -25,6 +25,10 @@ public class LoadingRepository { return inbounds.find(tenantId, inboundId); } + public LoadingInboundMapper.Snapshot findInboundForUpdate(long tenantId, long inboundId) { + return inbounds.findForUpdate(tenantId, inboundId); + } + public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java index 21410817b..8aac3146c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java @@ -11,13 +11,12 @@ import org.springframework.stereotype.Service; public class LoadingCompletionGateService { @Resource private LoadingCompletionGateMapper mapper; - public void requireBookingAndTransport(long tenantId, long inboundId) { + public void requireReleasedBooking(long tenantId, long inboundId) { LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId); if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) { throw blocked("未找到有效柜次关联"); } if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)"); - if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)"); } private static BizException blocked(String message) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 654431214..e2703301b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -24,6 +24,7 @@ import feign.FeignException; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -111,7 +112,7 @@ public class LoadingDeliveryService { } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); loadingModelBridge.requireCompleted(tenantId, inboundId); - completionGate.requireBookingAndTransport(tenantId, inboundId); + completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -120,6 +121,38 @@ public class LoadingDeliveryService { new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); } + /** + * PDA settlement may increment the source version and may create a loaded fulfillment slice. + * Resolve and lock the canonical completion row after settlement, then retain optimistic locking + * with that current server version instead of reusing the pre-settlement task snapshot. + */ + public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, + long operatorId, String operatorName, + String key, String remark) { + CompletionTarget target = repository.completionTarget(tenantId, inboundId); + long completionInboundId = target == null ? inboundId : target.completionInboundId(); + Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); + if (current == null || current.version() == null) { + throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); + } + return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + new CompleteLoadingRequest(current.version(), remark)); + } + + /** + * Read-only preflight used before PDA settlement. The current plan may still be LOADING here; + * every external/business hard gate must pass before the PDA task is mutated. + */ + public void requirePdaCompletionReady(long tenantId, long inboundId) { + Snapshot inbound = requireInbound(tenantId, inboundId); + if (!"LOADING".equals(inbound.processStatus())) { + throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); + } + loadingModelBridge.requireStarted(tenantId, inboundId); + completionGate.requireReleasedBooking(tenantId, inboundId); + requireCompletionEvidence(tenantId, inboundId); + } + private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request, @@ -132,7 +165,7 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); } loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); - completionGate.requireBookingAndTransport(tenantId, target.sourceInboundId()); + completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); requireCompletionEvidence(tenantId, target.sourceInboundId()); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, @@ -149,6 +182,22 @@ public class LoadingDeliveryService { return evidenceResponse(tenantId, inboundId); } + public ResponseEntity evidenceContent(long tenantId, long inboundId, long evidenceId) { + requireLoadingStage(tenantId, inboundId); + LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream() + .filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item)) + .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在")); + try { + ResponseEntity response = files.download(internalToken, tenantId, inboundId, evidence.getFileId()); + if (response == null || response.getBody() == null) { + throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); + } + return response; + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId, String operatorName, String key, VoidEvidenceRequest request) { @@ -333,11 +382,16 @@ public class LoadingDeliveryService { private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { List values = repository.listEvidence(tenantId, inboundId).stream().map(e -> new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), - e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(), + e.getFileName(), evidencePreviewUrl(tenantId, inboundId, e.getId()), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(), e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList(); return new EvidenceListResponse(inboundId, values); } + private static String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) { + return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId + + "/content/pda?tenantId=" + tenantId; + } + private static boolean isActive(LoadingEvidenceEntity evidence) { return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus())); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index b13344e11..1ae3ac1da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -11,9 +11,24 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface ContainerBookingMapper extends BaseMapper { + @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") + int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Select(""" ") Page page(Page p,@Param("q") Query q); + @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List activeForUpdate(@Param("containerBookingId") long containerBookingId); + @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java new file mode 100644 index 000000000..b27dc0ed5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.mapper; + +import com.baiyue.firstleg.dto.CurrentContainerTransport; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 统一订单按当前有效装柜明细批量读取柜次船期。 */ +@Mapper +public interface UnifiedInboundTransportMapper { + @Select(""" + + """) + List findCurrentByInboundIds(@Param("inboundIds") List inboundIds); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index 02269da22..a6ed9d70d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.List; @Validated @RestController @@ -26,40 +27,25 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long originWarehouseId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId)); + } @GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); } @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')") - @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false) - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } + @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱草稿", captureBefore=false) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") - @BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") - public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')") - @BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱") - public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')") - @BusinessAudit(event="OCEAN_BOOKING_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认海运订舱") - public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); } + @BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") + public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } + @DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')") + @BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿") + public ApiResponse deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); } @PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')") @BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱") public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')") - @BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败") - public ApiResponse fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:cancel')") - @BusinessAudit(event="OCEAN_BOOKING_CANCEL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="取消海运订舱") - public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/change-request") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") - @BusinessAudit(event="OCEAN_BOOKING_CHANGE_REQUEST", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="申请变更船期") - public ApiResponse requestChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ChangeRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.requestChange(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/change-confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") - @BusinessAudit(event="OCEAN_BOOKING_CHANGE_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认变更船期") - public ApiResponse confirmChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmChange(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") - @BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败") - public ApiResponse failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')") - @BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次") - public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); } + private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java index 8ad0e7374..0e916021f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java @@ -2,10 +2,5 @@ package com.baiyue.firstleg.oceanbooking.domain; public enum OceanBookingStatus { DRAFT, - SUBMITTED, - CONFIRMED, - RELEASED, - FAILED, - CHANGE_PENDING, - CANCELLED + RELEASED } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index de546a2ee..044d6b76f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -1,9 +1,8 @@ package com.baiyue.firstleg.oceanbooking.dto; -import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; -import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -34,17 +33,14 @@ public final class OceanBookingDtos { @Size(max = 64) String contactPhone) {} public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType, - @NotBlank @Size(max = 32) String containerKind, - @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, - @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, - @NotNull @Positive Integer quantity) {} - - public record DraftData(@NotNull @Positive Long customerId, - @NotBlank @Size(max = 128) String customerName, - @NotBlank @Size(max = 64) String channelCode, - @NotBlank @Size(max = 128) String channelName, - @NotNull @Positive Long warehouseId, - @NotBlank @Size(max = 128) String warehouseName, + @NotBlank @Size(max = 32) String containerKind, + @NotNull @Min(1) @Max(100) Integer quantity, + @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {} + public record DraftData(@Positive Long customerId, + @Size(max = 128) String customerName, + @Size(max = 64) String channelCode, + @Positive Long warehouseId, @Size(max = 128) String billOfLadingNo, @Positive Long shippingCompanyId, @Size(max = 128) String shippingCompanyName, @@ -62,34 +58,29 @@ public final class OceanBookingDtos { LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, - @Valid @NotEmpty @Size(max = 3) List parties, - @Valid @NotEmpty @Size(max = 20) List containerRequirements) {} + @Valid @Size(max = 3) List parties, + @Valid @Size(max = 20) List containerRequirements) {} public record CreateRequest(@NotNull @Min(0) Integer expectedVersion, @Valid @NotNull DraftData data) {} - public record UpdateDraftRequest(@NotNull @Min(0) Integer expectedVersion, - @Valid @NotNull DraftData data) {} + public record UpdateRequest(@NotNull @Min(0) Integer expectedVersion, + @Valid @NotNull DraftData data) {} public record ActionRequest(@NotNull @Min(0) Integer expectedVersion, @Size(max = 1000) String reason) {} public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 128) String billOfLadingNo, - @NotBlank @Size(max = 128) String vesselName, - @NotBlank @Size(max = 64) String voyageNo, - @NotNull LocalDateTime estimatedSailingAt, - @NotNull LocalDateTime estimatedArrivalAt, @Size(max = 1000) String reason) {} - public record ChangeRequest(@NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 128) String vesselName, - @NotBlank @Size(max = 64) String voyageNo, - LocalDateTime estimatedSailingAt, - LocalDateTime estimatedArrivalAt, - @NotBlank @Size(max = 1000) String reason) {} - public record AllocationRequest(@NotNull @Positive Long requirementId, - @NotNull @Positive Long containerBookingId, - @NotNull @Min(0) Integer expectedVersion) {} + public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo, + Long requirementId, Long allocationId, String containerType, + String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg, + Long warehouseId, String warehouseName, String channelCode, + String channelName, Long customerId, String customerName) {} + public record Detail(OceanBookingEntity oceanBooking, List parties, List containerRequirements, - List containerAllocations, + List containers, List histories) {} + + public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName, + String priceModel, List pricingTaxModes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index 07a8c84c3..f7cc765f5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -40,13 +40,6 @@ public class OceanBookingEntity { private String freightTerm; private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedArrivalAt; - private String pendingVesselName; - private String pendingVoyageNo; - private LocalDateTime pendingEstimatedSailingAt; - private LocalDateTime pendingEstimatedArrivalAt; - private String changeReason; - private String failureReason; - private String cancellationReason; private String remark; private Long createdBy; private String createdByName; @@ -55,4 +48,7 @@ public class OceanBookingEntity { private String updatedByName; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; @Version @TableField(fill = FieldFill.INSERT) private Integer version; + @TableField(exist = false) private String shipperName; + @TableField(exist = false) private String consigneeName; + @TableField(exist = false) private Long containerCount; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index ae6b28f6b..cca20bf30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -2,4 +2,9 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { + @Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}") + int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index aa235e6ab..d806df7a9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -5,103 +5,50 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; -import java.time.LocalDateTime; @Mapper public interface OceanBookingMapper extends BaseMapper { + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + int deleteDraft(@Param("id") long id, @Param("version") int version); @Select(""" """) Page selectPage(Page page, @Param("query") Query query); @Update(""" - UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName}, - channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName}, - bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId}, - shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId}, - booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo}, - departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName}, - destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName}, - place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm}, - estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt}, - remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{value.id} AND status='DRAFT' AND version=#{expectedVersion} - """) - int updateDraft(@Param("value") OceanBookingEntity value, @Param("expectedVersion") int expectedVersion, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status=#{toStatus},failure_reason=#{failureReason}, - cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName}, + UPDATE fl_ocean_booking SET status=#{toStatus}, + updated_by=#{operatorId},updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion} """) int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus, - @Param("toStatus") String toStatus, @Param("failureReason") String failureReason, - @Param("cancellationReason") String cancellationReason, - @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, + @Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - @Update(""" - UPDATE fl_ocean_booking SET status='RELEASED',bill_of_lading_no=#{billOfLadingNo}, - vessel_name=#{vesselName},voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt}, - estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion} - """) - int release(@Param("oceanBookingId") long oceanBookingId, @Param("billOfLadingNo") String billOfLadingNo, - @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, - @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt, - @Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, - @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName}, - pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt}, - pending_estimated_arrival_at=#{estimatedArrivalAt},change_reason=#{reason}, - updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion} - """) - int requestChange(@Param("oceanBookingId") long oceanBookingId, @Param("vesselName") String vesselName, - @Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt, - @Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, @Param("reason") String reason, - @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status='CONFIRMED',vessel_name=pending_vessel_name,voyage_no=pending_voyage_no, - estimated_sailing_at=pending_estimated_sailing_at,estimated_arrival_at=pending_estimated_arrival_at, - pending_vessel_name=NULL,pending_voyage_no=NULL,pending_estimated_sailing_at=NULL, - pending_estimated_arrival_at=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CHANGE_PENDING' AND version=#{expectedVersion} - """) - int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET version=version+1,updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{oceanBookingId} AND version=#{expectedVersion} - AND status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING') - """) - int touchForAllocation(@Param("oceanBookingId") long oceanBookingId, - @Param("expectedVersion") int expectedVersion, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java index dfa936258..bb66e852b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java @@ -19,7 +19,7 @@ public interface OceanBookingOperationMapper extends BaseMapper page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } + public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { + return bookings.transition(id, from, to, version, operatorId, operatorName); + } public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) { - return bookings.updateDraft(value, version, operatorId, operatorName); + return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName); } - public int transition(long id, String from, String to, String failureReason, String cancellationReason, - int version, long operatorId, String operatorName) { - return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName); - } - public int release(long id, ReleaseRequest request, long operatorId, String operatorName) { - return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(), - request.voyageNo().trim(), request.estimatedSailingAt(), request.estimatedArrivalAt(), - request.expectedVersion(), operatorId, operatorName); - } - public int requestChange(long id, OceanBookingEntity pending, String reason, int version, - long operatorId, String operatorName) { - return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(), - pending.getPendingEstimatedSailingAt(), pending.getPendingEstimatedArrivalAt(), reason, - version, operatorId, operatorName); - } - public int confirmChange(long id, int version, long operatorId, String operatorName) { - return bookings.confirmChange(id, version, operatorId, operatorName); - } - public int touchForAllocation(long id, int version, long operatorId, String operatorName) { - return bookings.touchForAllocation(id, version, operatorId, operatorName); + public int deleteDraft(long id, int version) { + return bookings.deleteDraft(id, version); } public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public List containers(long id) { + Map requirementById = requirements(id).stream() + .collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity())); + return allocations(id).stream().map(allocation -> { + OceanBookingContainerRequirementEntity requirement = requirementById.get(allocation.getRequirementId()); + ContainerBookingEntity container = containerBookings.find(allocation.getContainerBookingId()); + if (requirement == null || container == null) { + throw new IllegalStateException("订舱真实柜聚合关系不完整"); + } + return new RealContainer(container.getId(), container.getCabinetNo(), container.getContainerNo(), + requirement.getId(), allocation.getId(), requirement.getContainerType(), + requirement.getContainerKind(), requirement.getCapacityCbm(), requirement.getMaxWeightKg(), + container.getWarehouseId(), container.getWarehouseName(), container.getChannelCode(), + container.getChannelName(), container.getCustomerId(), container.getCustomerName()); + }).toList(); + } + public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); + } public List histories(long id) { return histories.selectList(new LambdaQueryWrapper().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); } public void replaceParties(long id, List values) { parties.delete(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); } public void replaceRequirements(long id, List values) { requirements.delete(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); } public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); } - public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); } - public void insert(ContainerBookingEntity value) { containerBookings.insert(value); } - public void insert(LoadingPlanEntity value) { loadingPlans.insert(value); } - public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) { - return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(), - request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName); + public boolean containerNoExists(String containerNo) { + return containerBookings.containerNoExists(containerNo, null); } + public void insertContainer(ContainerBookingEntity value) { containerBookings.insert(value); } public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); } + public int reassignAllocation(long sourceId, long targetId) { + return allocations.reassign(sourceId, targetId); + } + public void deleteAllocation(long containerBookingId) { allocations.delete(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); } public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); } public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; } public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); } - public int completeOperation(long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); } + public int completeOperation(Long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 926b32f25..5db6c6f52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -2,19 +2,25 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.ContainerBookingEntity; -import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; +import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -24,207 +30,117 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); + private static final Set OPERATIONAL_LIST_STATUSES = Set.of( + OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; + @Resource private PriceBookQueryRepository priceBooks; @Resource private ObjectMapper objectMapper; + @Resource private WarehouseSupplierSnapshotService suppliers; + @Resource private WarehouseMasterDataClient warehouses; + @Resource private FirstlegDocumentNumberGenerator documentNumbers; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; public PageResult page(Query query) { String status = trim(query.status()); - if (status != null) parseStatus(status); + if (status != null && !OPERATIONAL_LIST_STATUSES.contains(parseStatus(status))) { + throw badRequest("订舱列表仅支持草稿或已放舱状态"); + } return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public List channels(long originWarehouseId) { + if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) return List.of(); + List rows = priceBooks.channels(versionId, originWarehouseId); + Map> taxModes = priceBooks.channelTaxModes(versionId, originWarehouseId).stream() + .filter(row -> row != null && StringUtils.hasText(row.getChannelCode()) + && StringUtils.hasText(row.getPricingTaxMode())) + .collect(java.util.stream.Collectors.groupingBy(row -> row.getChannelCode().trim(), + java.util.stream.Collectors.mapping(ChannelTaxModeRow::getPricingTaxMode, + java.util.stream.Collectors.toList()))); + return rows.stream().map(row -> channelOption(row, taxModes)).toList(); + } + public Detail detail(long oceanBookingId) { return detail(required(oceanBookingId)); } @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { if (request.expectedVersion() != 0) throw concurrent(); - validateDraft(request.data()); + validateDraftStructure(request.data()); String action = "CREATE"; String hash = hash(action, null, request); Detail replay = claimOrReplay(null, operatorId, action, key, hash); if (replay != null) return replay; + ResolvedDraft resolved = resolveDraft(request.data()); OceanBookingEntity value = new OceanBookingEntity(); value.setId(IdWorker.getId()); - value.setBookingNo("OB" + value.getId()); + value.setBookingNo(documentNumbers.next("OB")); value.setStatus(OceanBookingStatus.DRAFT.name()); - copy(value, request.data()); + copy(value, request.data(), resolved); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties())); - repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements())); - addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, value, operatorId, operatorName); - return complete(value.getId(), operatorId, action, key, detail(value.getId())); + List requirements = requirements( + value.getId(), request.data().containerRequirements()); + repository.replaceRequirements(value.getId(), requirements); + OceanBookingEntity draft = required(value.getId()); + addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, draft, operatorId, operatorName); + return complete(value.getId(), operatorId, action, key, detail(draft)); } @Transactional - public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key, - UpdateDraftRequest request) { - validateDraft(request.data()); + public Detail updateDraft(long id, long operatorId, String operatorName, String key, UpdateRequest request) { String action = "UPDATE_DRAFT"; - Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.DRAFT); - OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data()); - if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); - repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties())); - repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements())); - return complete(oceanBookingId, operatorId, action, key, detail(oceanBookingId)); - } - - @Transactional public Detail submit(long id, long operatorId, String name, String key, ActionRequest request) { - return transition(id, operatorId, name, key, "SUBMIT", OceanBookingStatus.DRAFT, - OceanBookingStatus.SUBMITTED, request, false); - } - @Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) { - String action = "CONFIRM"; Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.SUBMITTED); - validateConfirmation(before, repository.requirements(id)); - if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), - null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); + requireStatus(before, OceanBookingStatus.DRAFT); + validateDraftStructure(request.data()); + ResolvedDraft resolved = resolveDraft(request.data()); + copy(before, request.data(), resolved); + if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); + repository.replaceParties(id, parties(id, request.data().parties())); + repository.replaceRequirements(id, requirements(id, request.data().containerRequirements())); OceanBookingEntity after = required(id); - generateContainers(after, repository.requirements(id), operatorId, name); - addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, - operatorId, name); + addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName); return complete(id, operatorId, action, key, detail(after)); } + @Transactional + public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) { + String action = "DELETE_DRAFT"; + Detail replay = claimOrReplay(null, operatorId, action, key, hash(action, id, request)); + if (replay != null) return; + OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); + requireStatus(before, OceanBookingStatus.DRAFT); + if (repository.deleteDraft(id, request.expectedVersion()) != 1) throw concurrent(); + complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of())); + } + @Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) { - validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt()); String action = "RELEASE"; Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.CONFIRMED); - if (repository.release(id, request, operatorId, name) != 1) throw concurrent(); - repository.syncReleasedTransport(id, request, operatorId, name); + requireStatus(before, OceanBookingStatus.DRAFT); + validateRelease(before, repository.parties(id), repository.requirements(id)); + if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(), + request.expectedVersion(), operatorId, name) != 1) throw concurrent(); OceanBookingEntity after = required(id); addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name); return complete(id, operatorId, action, key, detail(after)); } - @Transactional - public Detail fail(long id, long operatorId, String name, String key, ActionRequest request) { - if (!StringUtils.hasText(request.reason())) throw badRequest("失败原因不能为空"); - String action = "FAIL"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.SUBMITTED); - if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.FAILED.name(), trim(request.reason()), null, - request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, OceanBookingStatus.SUBMITTED.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } - @Transactional - public Detail failChange(long id, long operatorId, String name, String key, ActionRequest request) { - if (!StringUtils.hasText(request.reason())) throw badRequest("改船失败原因不能为空"); - return transition(id, operatorId, name, key, "FAIL_CHANGE", OceanBookingStatus.CHANGE_PENDING, - OceanBookingStatus.FAILED, request, true); - } - - @Transactional - public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { - if (!StringUtils.hasText(request.reason())) throw badRequest("取消原因不能为空"); - String action = "CANCEL"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - OceanBookingStatus from = parseStatus(before.getStatus()); - if (from == OceanBookingStatus.RELEASED || from == OceanBookingStatus.CANCELLED) { - throw conflict("订舱已越过不可逆节点或已经取消"); - } - if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(), null, - trim(request.reason()), request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, from.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } - - @Transactional - public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) { - validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt()); - String action = "REQUEST_CHANGE"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.CONFIRMED); - OceanBookingEntity pending = new OceanBookingEntity(); - pending.setPendingVesselName(request.vesselName().trim()); - pending.setPendingVoyageNo(request.voyageNo().trim()); - pending.setPendingEstimatedSailingAt(request.estimatedSailingAt()); - pending.setPendingEstimatedArrivalAt(request.estimatedArrivalAt()); - if (repository.requestChange(id, pending, request.reason().trim(), request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } - - @Transactional - public Detail confirmChange(long id, long operatorId, String name, String key, ActionRequest request) { - String action = "CONFIRM_CHANGE"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.CHANGE_PENDING); - if (repository.confirmChange(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } - - @Transactional - public Detail allocate(long id, long operatorId, String name, String key, AllocationRequest request) { - String action = "ALLOCATE_CONTAINER"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity booking = requiredVersion(id, request.expectedVersion()); - if (!Set.of(OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), - OceanBookingStatus.CHANGE_PENDING.name()).contains(booking.getStatus())) { - throw conflict("当前订舱状态不可分配柜次"); - } - OceanBookingContainerRequirementEntity requirement = repository.requirement(id, request.requirementId()); - if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "箱型需求不存在"); - ContainerBookingEntity container = repository.containerBooking(request.containerBookingId()); - if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); - if (!requirement.getContainerType().equals(container.getContainerType())) throw conflict("柜次箱型与订舱需求不一致"); - requireSameChannel(booking, container); - long used = repository.allocations(id).stream().filter(v -> request.requirementId().equals(v.getRequirementId())).count(); - if (used >= requirement.getQuantity()) throw conflict("该箱型需求已分配完毕"); - if (repository.touchForAllocation(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(id); - allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(request.containerBookingId()); - allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name); allocation.setAllocatedAt(LocalDateTime.now()); - try { repository.insert(allocation); } catch (DuplicateKeyException ex) { throw conflict("柜次已被其他订舱占用"); } - return complete(id, operatorId, action, key, detail(id)); - } - - private Detail transition(long id, long operatorId, String name, String key, String action, - OceanBookingStatus from, OceanBookingStatus to, ActionRequest request, - boolean failure) { - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, from); - if (from == OceanBookingStatus.DRAFT && to == OceanBookingStatus.SUBMITTED) validateSubmission(id); - if (repository.transition(id, from.name(), to.name(), failure ? trim(request.reason()) : null, null, - request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, from.name(), to.name(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } private Detail claimOrReplay(Long id, long operatorId, String action, String key, String hash) { validateKey(key); @@ -236,7 +152,7 @@ public class OceanBookingService { catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取订舱幂等结果", ex); } } - private Detail complete(long id, long operatorId, String action, String key, Detail detail) { + private Detail complete(Long id, long operatorId, String action, String key, Detail detail) { try { if (repository.completeOperation(id, operatorId, action, key, objectMapper.writeValueAsString(detail)) != 1) { throw conflict("订舱幂等请求完成状态冲突"); @@ -254,105 +170,116 @@ public class OceanBookingService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history); } - private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.allocations(value.getId()), repository.histories(value.getId())); } + private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); } private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; } private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; } private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } - private void validateDraft(DraftData data) { - if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null - || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) - || StringUtils.hasText(data.voyageNo())) { - throw badRequest("真实运输数据请在确认放舱时录入"); - } - Set types = new HashSet<>(); - for (PartyRequest party : data.parties()) { + private void validateDraftStructure(DraftData data) { + boolean hasCustomerId = data.customerId() != null; + boolean hasCustomerName = StringUtils.hasText(data.customerName()); + if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空"); + if (data.parties() != null) { + Set types = new HashSet<>(); + for (PartyRequest party : data.parties()) { String type = party.partyType().trim().toUpperCase(); if (!PARTY_TYPES.contains(type) || !types.add(type)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY"); + } } - if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY"); - } - private void validateSubmission(long id) { - Set types = repository.parties(id).stream().map(OceanBookingPartyEntity::getPartyType) - .collect(java.util.stream.Collectors.toSet()); - if (!types.equals(PARTY_TYPES) || repository.requirements(id).isEmpty()) { - throw conflict("订舱方或箱型需求不完整,不能提交"); - } - } - private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } - private static void copy(OceanBookingEntity v, DraftData d) { - v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); - v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim()); - v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); - v.setBillOfLadingNo(null); - v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); - v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName())); - v.setVesselName(null); v.setVoyageNo(null); - v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); - v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); - v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm())); - v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark())); - } - - private void generateContainers(OceanBookingEntity booking, - List requirements, - long operatorId, String operatorName) { - for (OceanBookingContainerRequirementEntity requirement : requirements) { - for (int sequence = 1; sequence <= requirement.getQuantity(); sequence++) { - ContainerBookingEntity container = new ContainerBookingEntity(); - container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId()); - container.setCabinetNo("CAB-" + booking.getBookingNo() + '-' + requirement.getSortNo() + '-' + sequence); - container.setContainerNo(null); container.setChannelCode(booking.getChannelCode()); - container.setChannelName(booking.getChannelName()); container.setOceanBookingId(booking.getId()); - container.setOceanBookingRequirementId(requirement.getId()); - container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName()); - container.setContainerType(requirement.getContainerType()); - container.setCapacityCbm(requirement.getCapacityCbm()); - container.setMaxWeightKg(requirement.getMaxWeightKg()); - container.setShippingCompanyId(booking.getShippingCompanyId()); - container.setShippingCompanyName(booking.getShippingCompanyName()); - container.setDeparturePortCode(booking.getDeparturePortCode()); - container.setDeparturePortName(booking.getDeparturePortName()); - container.setDestinationPortCode(booking.getDestinationPortCode()); - container.setDestinationPortName(booking.getDestinationPortName()); - container.setCreatedBy(operatorId); container.setCreatedByName(operatorName); - container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0); - repository.insert(container); - LoadingPlanEntity plan = new LoadingPlanEntity(); - plan.setPlanNo("LP-" + container.getId()); plan.setContainerBookingId(container.getId()); - plan.setPlanStatus("EDITING"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); - plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0); - repository.insert(plan); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId()); - allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(container.getId()); - allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); - allocation.setAllocatedAt(LocalDateTime.now()); repository.insert(allocation); + if (data.containerRequirements() != null) { + for (ContainerRequirementRequest container : data.containerRequirements()) { + ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg()); } } } - - private static void validateConfirmation(OceanBookingEntity booking, - List requirements) { - if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName()) - || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getWarehouseName())) { - throw conflict("订舱缺少渠道或国内中转仓快照,不能确认"); - } - if (requirements.isEmpty() || requirements.stream().anyMatch(requirement -> - requirement.getCapacityCbm() == null || requirement.getMaxWeightKg() == null)) { - throw conflict("箱型需求缺少容量或最大承重快照,不能确认"); - } + private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } + private static void validateRelease(OceanBookingEntity booking, List parties, + List requirements) { + if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName()) + || !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null + || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) + || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) + || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null + || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || booking.getBookingAgentId() == null || parties == null || parties.size() != 3 + || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); + validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); + } + private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { + if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + String normalized = normalizeChannel(channelCode); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); + ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream() + .filter(value -> normalized.equals(normalizeChannel(value.getChannelCode()))) + .findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); + if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效"); + return channelOption(row, java.util.Map.of()); } + private static ChannelOption channelOption(ChannelRow row, java.util.Map> taxModes) { + return new ChannelOption(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(), + row.getPriceModel(), List.copyOf(taxModes.getOrDefault(row.getChannelCode().trim(), List.of()))); + } - private static void requireSameChannel(OceanBookingEntity booking, ContainerBookingEntity container) { - if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(container.getChannelCode())) { - throw conflict("订舱或柜次缺少渠道快照,请先完成存量数据回填"); + private static String normalizeChannel(String value) { + if (!StringUtils.hasText(value)) throw badRequest("channelCode不能为空"); + return value.trim(); + } + + private record ResolvedDraft(String warehouseName, String channelName, String bookingAgentName) {} + private ResolvedDraft resolveDraft(DraftData d) { + String warehouseName = null; String channelName = null; String bookingAgentName = trim(d.bookingAgentName()); + if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { + WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); + warehouseName = warehouse.warehouseName(); + channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName(); + } + if (d.bookingAgentId() != null) { + var snapshot = suppliers.booking(d.bookingAgentId(), d.bookingAgentName()); + bookingAgentName = snapshot == null ? null : snapshot.name(); + } else if (StringUtils.hasText(d.bookingAgentName())) throw badRequest("订舱代理名称不能脱离供应商ID提交"); + return new ResolvedDraft(warehouseName, channelName, bookingAgentName); + } + private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) { + v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); + v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName()); + v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); + v.setBillOfLadingNo(trim(d.billOfLadingNo())); + v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); + v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName()); + v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); + v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); + v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); + v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm())); + v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); + v.setRemark(trim(d.remark())); + } + + private static List parties(long id, List requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); } + private static List requirements(long id, List requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } + private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { + try { + ApiResponse response = warehouses.active( + internalToken, publicWarehouseTenantId, warehouseId); + if (response == null || !response.successful() || response.data() == null + || response.data().warehouse() == null) { + throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用"); + } + WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse(); + if (!Long.valueOf(warehouseId).equals(warehouse.id()) + || !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId()) + || !"ACTIVE".equals(warehouse.status()) || !"DOMESTIC".equals(warehouse.warehouseType()) + || !StringUtils.hasText(warehouse.warehouseName())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "订舱只能选择启用中的国内中转仓"); + } + return warehouse; + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用"); } - if (!booking.getChannelCode().equals(container.getChannelCode())) throw conflict("柜次渠道与订舱渠道不一致"); } - private static List parties(long id, List requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); } - private static List requirements(long id, List requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } } private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } } private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index c9b54e718..38c848ca6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -20,7 +20,8 @@ public final class OutboundDispatchDtos { public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String containerNo, Long loadingPlanId, String loadingPlanStatus, + Long containerBookingId, String cabinetNo, String containerNo, + Long loadingPlanId, String loadingPlanStatus, Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, String dispatchStatus, Integer version, GateResult gate) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index c519670c0..7bee3dbce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -15,7 +15,7 @@ public interface OutboundDispatchReadMapper { SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, - booking.id containerBookingId,booking.container_no containerNo, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, plan.id loadingPlanId,plan.plan_status loadingPlanStatus FROM fl_loading_plan_item item JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id @@ -100,7 +100,7 @@ public interface OutboundDispatchReadMapper { record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, Long customerId, String customerName, String inboundStatus, Long containerBookingId, - String containerNo, Long loadingPlanId, String loadingPlanStatus) {} + String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index c90c6e4e7..ed32bcb61 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -109,7 +109,8 @@ public class OutboundDispatchService { GateResult gate = gate(binding, pick, ocean, truck, evidence); return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.containerNo(), binding.loadingPlanId(), binding.loadingPlanStatus(), + binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), + binding.loadingPlanId(), binding.loadingPlanStatus(), id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), dispatch == null ? 0 : dispatch.getVersion(), gate); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 77a3b8a0c..e1733566e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -73,8 +73,9 @@ public class ActivePriceBookEstimator { } private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) { - if (request.destinationType() != PricingDestinationType.FBA) { - throw validation("海派渠道仅支持FBA目的仓参考估价"); + if (request.destinationType() != PricingDestinationType.FBA + && request.destinationType() != PricingDestinationType.OVERSEAS) { + throw validation("海派渠道仅支持平台FBA仓或海外中转仓参考估价"); } if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); @@ -95,7 +96,7 @@ public class ActivePriceBookEstimator { private PriceRegion resolveWarehouseRegion(Long warehouseId) { if (warehouseId == null || warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } return warehouseRegionResolver.resolve(warehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java index 7123a2a3c..2d0189243 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */ +/** Resolves a platform destination warehouse to the fixed sea-parcel price region. */ @Component public class PriceBookWarehouseRegionResolver { @Resource private PriceBookWarehouseClient warehouses; @@ -20,7 +20,7 @@ public class PriceBookWarehouseRegionResolver { public PriceRegion resolve(long warehouseId) { if (warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } try { ApiResponse response = warehouses.active(internalToken, 0L, warehouseId); @@ -28,12 +28,13 @@ public class PriceBookWarehouseRegionResolver { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用"); } if (!response.successful() || response.data() == null || response.data().warehouse() == null) { - throw validation("目的仓不是可用的平台FBA仓"); + throw validation("目的仓不是可用的平台目的仓"); } var warehouse = response.data().warehouse(); if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId()) - || !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) { - throw validation("目的仓不是可用的平台FBA仓"); + || !("FBA".equals(warehouse.warehouseType()) || "OVERSEAS".equals(warehouse.warehouseType())) + || !"ACTIVE".equals(warehouse.status())) { + throw validation("目的仓不是可用的平台FBA仓或海外中转仓"); } String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream() .filter(address -> address != null && StringUtils.hasText(address.postalCode())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java index 9a7ebb587..d490ba2b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java @@ -7,6 +7,8 @@ import com.baiyue.firstleg.mapper.ContainerFeeDetailMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -40,6 +42,22 @@ public class ContainerFeeDetailRepository { mapper.insert(value); } + public int updateSupplierSnapshot(long tenantId, long loadingPlanId, long feeId, + Long supplierId, String supplierName, + String supplierCategory, long operatorId) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(ContainerFeeDetailEntity::getTenantId, tenantId) + .eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId) + .eq(ContainerFeeDetailEntity::getId, feeId) + .eq(ContainerFeeDetailEntity::getReviewStatus, "PENDING") + .eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE") + .set(ContainerFeeDetailEntity::getSupplierId, supplierId) + .set(ContainerFeeDetailEntity::getSupplierName, supplierName) + .set(ContainerFeeDetailEntity::getSupplierCategory, supplierCategory) + .set(ContainerFeeDetailEntity::getUpdatedBy, operatorId) + .set(ContainerFeeDetailEntity::getUpdatedAt, LocalDateTime.now())); + } + public int review(long tenantId, long loadingPlanId, long feeId, String status, long reviewerId, String reviewerName, String reason) { return mapper.update(null, new LambdaUpdateWrapper() @@ -56,14 +74,26 @@ public class ContainerFeeDetailRepository { .set(ContainerFeeDetailEntity::getUpdatedBy, reviewerId)); } - public int adjust(ContainerFeeDetailEntity value, int expectedVersion) { - return mapper.update(value, new LambdaUpdateWrapper() - .eq(ContainerFeeDetailEntity::getTenantId, value.getTenantId()) - .eq(ContainerFeeDetailEntity::getLoadingPlanId, value.getLoadingPlanId()) - .eq(ContainerFeeDetailEntity::getId, value.getId()) + public int adjust(long tenantId, long loadingPlanId, long feeId, int expectedVersion, + LocalDate businessDate, BigDecimal amount, long operatorId, + LocalDateTime updatedAt) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(ContainerFeeDetailEntity::getTenantId, tenantId) + .eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId) + .eq(ContainerFeeDetailEntity::getId, feeId) .eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED") .eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE") - .eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion)); + .eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion) + .set(ContainerFeeDetailEntity::getBusinessDate, businessDate) + .set(ContainerFeeDetailEntity::getAmount, amount) + .set(ContainerFeeDetailEntity::getSourceVersion, expectedVersion + 1) + .set(ContainerFeeDetailEntity::getReviewStatus, "PENDING") + .set(ContainerFeeDetailEntity::getReviewerId, null) + .set(ContainerFeeDetailEntity::getReviewerName, null) + .set(ContainerFeeDetailEntity::getReviewedAt, null) + .set(ContainerFeeDetailEntity::getReviewReason, null) + .set(ContainerFeeDetailEntity::getUpdatedBy, operatorId) + .set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt)); } public int voidApproved(long tenantId, long loadingPlanId, long feeId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java new file mode 100644 index 000000000..989377634 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity; +import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +@RequiredArgsConstructor +public class ContainerFeeSourceSnapshotRepository { + private final ContainerFeeSourceSnapshotMapper mapper; + + public void saveApproved(ContainerFeeDetailEntity source) { + ContainerFeeSourceSnapshotEntity value = new ContainerFeeSourceSnapshotEntity(); + value.setSourceFeeId(source.getId()); + value.setSourceVersion(source.getSourceVersion()); + value.setTenantId(source.getTenantId()); + value.setLoadingPlanId(source.getLoadingPlanId()); + value.setContainerBookingId(source.getContainerBookingId()); + value.setContainerNo(source.getContainerNo()); + value.setFeeItemId(source.getFeeItemId()); + value.setFeeItemCode(source.getFeeItemCodeSnapshot()); + value.setFeeItemName(source.getFeeItemNameSnapshot()); + value.setDirection(source.getDirection()); + value.setSupplierId(source.getSupplierId()); + value.setSupplierName(source.getSupplierName()); + value.setSupplierCategory(source.getSupplierCategory()); + value.setBusinessDate(source.getBusinessDate()); + value.setAmount(source.getAmount()); + value.setCurrency(source.getCurrency()); + value.setReviewStatus(source.getReviewStatus()); + value.setReviewedAt(source.getReviewedAt()); + value.setCreatedAt(LocalDateTime.now()); + mapper.insert(value); + } + + public ContainerFeeSourceSnapshotEntity find(long loadingPlanId, long sourceFeeId, + int sourceVersion) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId) + .eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId) + .eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index fcc1568be..60fbe9d8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -17,6 +17,7 @@ public class ContainerRepository { } public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); } + public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); } public boolean containerNoExists(String no, Long excludedId) { if (no == null) return false; return mapper.selectCount(new LambdaQueryWrapper() @@ -29,11 +30,31 @@ public class ContainerRepository { .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; } public void insert(ContainerBookingEntity value) { mapper.insert(value); } + public int unlinkReleasedBooking(long targetId, int version, long operatorId, String operatorName) { + return mapper.unlinkReleasedBooking(targetId, version, operatorId, operatorName); + } + public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo, + com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking, + int version, long operatorId, String operatorName) { + return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName); + } public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { return mapper.updateDraft(value, version, operatorId, operatorName); } + public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; } + public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; } + public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } + public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } + public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } + public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; } public int updateTransport(long id, String containerNo, String voyageNo, int version, long operatorId, String operatorName) { return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); } + public int syncReleasedTransport(long oceanBookingId, String billOfLadingNo, String vesselName, + String voyageNo, java.time.LocalDateTime estimatedSailingAt, + long operatorId, String operatorName) { + return mapper.syncReleasedTransport(oceanBookingId, billOfLadingNo, vesselName, voyageNo, + estimatedSailingAt, operatorId, operatorName); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index e8b16f2d6..e0f5201de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -192,15 +192,30 @@ public class FbaPricingRepository { .orderByAsc(InboundFeeItemEntity::getItemNo)); } - public int supersedeSent(long tenantId, long feeId) { + public int supersedeCurrent(long tenantId, long feeId, String feeStatus) { return fees.update(null, new LambdaUpdateWrapper() .eq(InboundFeeEntity::getTenantId, tenantId) .eq(InboundFeeEntity::getId, feeId) - .eq(InboundFeeEntity::getFeeStatus, "SENT") + .eq(InboundFeeEntity::getFeeStatus, feeStatus) .set(InboundFeeEntity::getFeeStatus, "SUPERSEDED") .set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now())); } + public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus, + String chargeUnit, java.math.BigDecimal chargeableQuantity) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getId, inboundId) + .eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT") + .set(InboundPricingEntity::getFeeStatus, feeStatus); + if ("KG".equals(chargeUnit)) { + update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity); + } else { + update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity); + } + return inbounds.update(null, update); + } + public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, java.math.BigDecimal chargeable) { LambdaUpdateWrapper update = new LambdaUpdateWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 22ea7eac0..0008d8859 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -98,11 +98,13 @@ public class FeeItemRepository { .eq(InboundOrderEntity::getId, inboundId)); } - public List listDetails(long tenantId, long inboundId) { + public List listDetails(long tenantId, long inboundId, boolean includeHistory) { return feeDetails.selectList(new LambdaQueryWrapper() .eq(InboundFeeDetailEntity::getTenantId, tenantId) .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .ne(!includeHistory, InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") .orderByDesc(InboundFeeDetailEntity::getBusinessDate) + .orderByDesc(InboundFeeDetailEntity::getSourceFeeVersion) .orderByDesc(InboundFeeDetailEntity::getId)); } @@ -119,6 +121,15 @@ public class FeeItemRepository { .eq(InboundFeeDetailEntity::getId, detailId)); } + public InboundFeeDetailEntity findSourceVersion(long inboundId, long sourceFeeId, + int sourceVersion) { + return feeDetails.selectOne(new LambdaQueryWrapper() + .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .eq(InboundFeeDetailEntity::getSourceFeeVersion, sourceVersion) + .and(scope -> scope.eq(InboundFeeDetailEntity::getId, sourceFeeId) + .or().eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId))); + } + public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) { return feeDetails.selectOne(new LambdaQueryWrapper() .eq(InboundFeeDetailEntity::getSourceType, sourceType) @@ -155,6 +166,38 @@ public class FeeItemRepository { .set(InboundFeeDetailEntity::getUpdatedAt, now)); } + public int supersedeFreight(long tenantId, long inboundId, long retainedSourceFeeId, + long operatorId, LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING") + .in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED")) + .ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId) + .set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); + } + + public int publishCalculatedFreight(long tenantId, long sourceFeeId, long operatorId, + String operatorName, LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING") + .eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId) + .eq(InboundFeeDetailEntity::getPublicationStatus, "DRAFT") + .set(InboundFeeDetailEntity::getReviewStatus, "APPROVED") + .set(InboundFeeDetailEntity::getReviewerId, operatorId) + .set(InboundFeeDetailEntity::getReviewerName, operatorName) + .set(InboundFeeDetailEntity::getReviewedAt, now) + .set(InboundFeeDetailEntity::getPublicationStatus, "PUBLISHED") + .set(InboundFeeDetailEntity::getPublishedBy, operatorId) + .set(InboundFeeDetailEntity::getPublishedByName, operatorName) + .set(InboundFeeDetailEntity::getPublishedAt, now) + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); + } + public void insertDetail(InboundFeeDetailEntity detail) { feeDetails.insert(detail); } @@ -189,13 +232,20 @@ public class FeeItemRepository { .set(InboundFeeDetailEntity::getUpdatedBy, reviewerId)); } - public int adjustApproved(InboundFeeDetailEntity detail, int expectedSourceVersion) { - return feeDetails.update(detail, new LambdaUpdateWrapper() - .eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId()) - .eq(InboundFeeDetailEntity::getInboundId, detail.getInboundId()) - .eq(InboundFeeDetailEntity::getId, detail.getId()) + public int supersedeApprovedManual(long tenantId, long inboundId, long detailId, + int expectedSourceVersion, long operatorId, + LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .eq(InboundFeeDetailEntity::getId, detailId) + .eq(InboundFeeDetailEntity::getSourceType, "MANUAL") .eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED") - .eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion)); + .eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion) + .ne(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); } public int voidApproved(long tenantId, long inboundId, long detailId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index d367442c7..8bc8cf9f3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -59,7 +59,8 @@ public class InboundMeasurementRepository { return inboundOrders.update(inbound, new LambdaUpdateWrapper() .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) .eq(InboundOrderEntity::getId, inbound.getId()) - .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())) == 1; + .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg()) + .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1; } public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 477d67bc5..3d9de0415 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -14,12 +14,14 @@ import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity; import com.baiyue.firstleg.mapper.InboundFileMapper; import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -37,6 +39,8 @@ public class InboundOrderRepository { @Resource private InboundTransportSnapshotMapper transports; @Resource + private InboundPickupSnapshotMapper pickupSnapshots; + @Resource private InboundPalletAllocationMapper pallets; @Resource private InboundFileMapper files; @@ -82,9 +86,11 @@ public class InboundOrderRepository { Map tenantByInbound = result.getRecords().stream() .collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId)); Map transportByInbound = transportMap(tenantId, tenantByInbound); + Map pickupByInbound = pickupMap(tenantId, tenantByInbound); Map locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound); List records = result.getRecords().stream() .map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()), + pickupByInbound.get(order.getId()), locationCodesByInbound.get(order.getId()))) .toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); @@ -148,29 +154,31 @@ public class InboundOrderRepository { + "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))", query.feeReviewStatus()) .apply(StringUtils.hasText(query.containerNo()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND b.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) .apply(StringUtils.hasText(query.cabinetNo()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND b.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) .apply(query.sailingDate() != null, - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.sailing_at >= {0} AND t.sailing_at < {1})", + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) " + + ">= {0} AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) < {1})", query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(), query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay()) .apply(Boolean.TRUE.equals(query.loaded()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND COALESCE(TRIM(t.loading_status), '') <> '' " - + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' " + + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)") .apply(Boolean.FALSE.equals(query.loaded()), - "NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND COALESCE(TRIM(t.loading_status), '') <> '' " - + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") + "NOT EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' " + + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)") .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); @@ -191,6 +199,9 @@ public class InboundOrderRepository { InboundTransportSnapshotEntity transport = transports.selectOne( owned(InboundTransportSnapshotEntity::getTenantId, InboundTransportSnapshotEntity::getInboundId, tenantId, inboundId)); + InboundPickupSnapshotEntity pickup = pickupSnapshots.selectOne( + owned(InboundPickupSnapshotEntity::getTenantId, + InboundPickupSnapshotEntity::getInboundId, tenantId, inboundId)); List palletAllocations = pallets.selectList( owned(InboundPalletAllocationEntity::getTenantId, InboundPalletAllocationEntity::getInboundId, tenantId, inboundId) @@ -228,7 +239,7 @@ public class InboundOrderRepository { .map(InboundPalletAllocationEntity::getLocationCode) .filter(StringUtils::hasText) .collect(Collectors.joining("、")); - return new InboundOrderDetail(InboundOrderSummary.from(order, transport, locationCodes), + return new InboundOrderDetail(InboundOrderSummary.from(order, transport, pickup, locationCodes), order.getPublicRemark(), palletSummaries, fileSummaries, noteSummaries, historySummaries); } @@ -247,6 +258,17 @@ public class InboundOrderRepository { Function.identity(), (left, right) -> left)); } + public Map pickupMap( + Long tenantId, Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return pickupSnapshots.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, InboundPickupSnapshotEntity::getTenantId, tenantId) + .in(InboundPickupSnapshotEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundPickupSnapshotEntity::getInboundId, + Function.identity(), (left, right) -> left)); + } + public Map locationCodesMap(Long tenantId, Map tenantByInbound) { if (tenantByInbound.isEmpty()) { return Map.of(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index ea7358e8f..0252250f9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -22,20 +22,17 @@ public class InboundPalletAllocationRepository { @Transactional public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, - BigDecimal capacity, int totalPackageCount, BatchResult result) { + int totalPackageCount, BatchResult result) { Map reservationNos = mapper.selectList(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) - .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getPalletId, + .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId, InboundPalletAllocationEntity::getReservationNo, (left, right) -> left)); mapper.delete(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)); LocalDateTime now = LocalDateTime.now(); int allocationCount = result.allocations().size(); - if (allocationCount > totalPackageCount) { - throw new IllegalArgumentException("托盘数量不能超过入库单总箱数"); - } int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount; int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount; for (int index = 0; index < result.allocations().size(); index++) { @@ -48,7 +45,7 @@ public class InboundPalletAllocationRepository { value.setWarehouseResourceTenantId(warehouseResourceTenantId); value.setPalletId(source.palletId()); value.setPalletCode(source.palletCode()); - value.setPalletCapacityCbm(capacity); + value.setPalletCapacityCbm(source.palletCapacityCbm()); value.setAllocatedVolumeCbm(source.allocatedVolumeCbm()); int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0); value.setAllocatedPackageCount(allocatedPackages); @@ -58,7 +55,7 @@ public class InboundPalletAllocationRepository { value.setReservationId(source.reservationId()); // 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。 value.setReservationNo(source.reservationNo() != null ? source.reservationNo() - : reservationNos.get(source.palletId())); + : reservationNos.get(source.reservationId())); value.setAllocationStatus(normalize(source.state())); value.setCreatedAt(now); value.setUpdatedAt(now); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index d190c79ba..86fad576e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; @@ -85,6 +86,17 @@ public class LoadingListRepository { return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); } + public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion, + long operatorId, String operatorName) { + return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion, + operatorId, operatorName); + } + + public int touchForPreloadConfirmation(long planId, int expectedVersion, + long operatorId, String operatorName) { + return mapper.touchForPreloadConfirmation(planId, expectedVersion, operatorId, operatorName); + } + public int advance(long planId, String fromStatus, String toStatus, int expectedVersion, long operatorId, String operatorName) { return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName); @@ -116,7 +128,36 @@ public class LoadingListRepository { customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(), row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()), value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()), - decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion()); + decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion(), readiness(row)); + } + + private PreloadReadiness readiness(AggregateRow row) { + boolean pushed = row.getPreloadPushedAt() != null; + boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo()); + int linkCount = Math.toIntExact(value(row.getPreloadLinkCount())); + int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount())); + boolean oceanLinked = row.getOceanBookingId() != null; + boolean released = "RELEASED".equals(row.getOceanBookingStatus()); + boolean editing = "EDITING".equals(row.getPlanStatus()); + boolean taskCreated = row.getPdaTaskId() != null; + java.util.ArrayList blockers = new java.util.ArrayList<>(); + if (!pushed) blockers.add("PRELOAD_NOT_PUSHED"); + if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); + if (!containerReady) blockers.add("CONTAINER_NO_MISSING"); + if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); + if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); + else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); + if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); + boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; + boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released; + boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; + if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); + String confirmationStatus = allConfirmed ? "CONFIRMED" + : confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING"; + return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount, + row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(), + row.getPdaTaskStatus(), confirmationStatus, canConfirm, + prerequisitesReady && allConfirmed, blockers); } private ConfirmedSummary confirmed(AggregateRow row) { @@ -130,14 +171,15 @@ public class LoadingListRepository { private List items(List rows) { return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(), - row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(), + row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(), + row.getInboundCode(), row.getContainerNo(), row.getSkuCode(), row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(), - integer(row.getLoadSequence()), row.getFrozenAt())).toList(); + integer(row.getLoadSequence()), row.getFrozenAt(), row.getItemVersion())).toList(); } private List histories(long planId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 9cf569d79..683f1e174 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; @@ -17,6 +18,7 @@ import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -40,6 +42,8 @@ public class LoadingPickRepository { private LoadingPickOperationMapper operations; @Resource private InboundLoadingPickTaskMapper inboundProjections; + @Resource + private LoadingInboundMapper inbounds; public LoadingPickTaskEntity task(long taskId) { return tasks.selectById(taskId); @@ -78,6 +82,10 @@ public class LoadingPickRepository { return tasks.selectPendingForOperator(operatorId, keyword); } + public ContainerBookingEntity activeBookingByTask(long taskId) { + return tasks.selectActiveBookingByTask(taskId); + } + public LoadingPickItemEntity item(long taskId, long itemId) { return items.selectOne(new LambdaQueryWrapper() .eq(LoadingPickItemEntity::getPickTaskId, taskId) @@ -209,6 +217,22 @@ public class LoadingPickRepository { return items.selectInboundProgress(tenantId, inboundId); } + public List inboundOwners(long taskId) { + return items.selectInboundOwners(taskId); + } + + public LoadingInboundMapper.Snapshot inbound(long tenantId, long inboundId) { + return inbounds.find(tenantId, inboundId); + } + + public LoadingInboundMapper.Snapshot inboundForUpdate(long tenantId, long inboundId) { + return inbounds.findForUpdate(tenantId, inboundId); + } + + public int beginInboundLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { + return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion); + } + public int settlePartial(long planItemId, long pickItemId, int pickedCount, int plannedCount, long operatorId, String operatorName) { if (items.settlePartialPlanItem(planItemId, pickedCount, plannedCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java index d7c39b579..1161bf9e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java @@ -46,4 +46,8 @@ public class PriceBookQueryRepository { return channels(versionId, originWarehouseId).stream() .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); } + + public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { + return mapper.findChannelPrefix(channelCode); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java index 916fe8869..dc8395fc6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java @@ -10,6 +10,9 @@ import java.time.LocalDate; public final class PriceBookQueryRows { private PriceBookQueryRows() {} + public record ChannelPrefixRow(String channelCode, String inboundCodePrefix, + String overseasInboundCodePrefix) {} + @Getter @Setter public static class ChannelRow { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 7cf27b508..cad44c116 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -69,13 +69,15 @@ public class SmartLoadingRepository { public int remove(long planId, long itemId, int version, long operatorId, String operatorName) { return items.removeEditing(planId, itemId, version, operatorId, operatorName); } + public int updateWarehouseRemark(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion, + String remark, long operatorId, String operatorName) { + return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion, + remark, operatorId, operatorName); + } public int push(long planId, String key, int version, long operatorId, String operatorName) { return plans.pushPreload(planId, key, version, operatorId, operatorName); } public int startPicking(long planId, long operatorId, String operatorName) { return plans.startPicking(planId, operatorId, operatorName); } - public int recycleUnpicked(long planId, int expectedVersion, long operatorId, String operatorName) { - return plans.recycleUnpicked(planId, expectedVersion, operatorId, operatorName); - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index 2bb033a54..232ce6740 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -1,7 +1,8 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations; - public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} + public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} + public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java index 2ca7baca5..5a8fbe592 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -8,9 +8,11 @@ import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; -import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.dto.CurrentContainerTransport; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; -import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -26,7 +28,7 @@ public class UnifiedInboundReadRepository { @Resource private InboundLoadingPickTaskMapper picks; @Resource private LoadingEvidenceMapper evidences; @Resource private InboundStatusHistoryMapper histories; - @Resource private InboundTransportSnapshotMapper transports; + @Resource private UnifiedInboundTransportMapper transports; public PreloadPlanEntity findPreload(long tenantId, long inboundId) { return preloads.selectOne(new LambdaQueryWrapper() @@ -84,19 +86,23 @@ public class UnifiedInboundReadRepository { Function.identity(), (latest, ignored) -> latest)); } - public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) { - return transports.selectOne(new LambdaQueryWrapper() - .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) - .eq(InboundTransportSnapshotEntity::getInboundId, inboundId)); + public CurrentContainerTransport findTransport(long tenantId, long inboundId) { + return findTransports(Map.of(inboundId, tenantId)).get(inboundId); } - public Map findTransports(Map tenantByInbound) { + public Map findTransports(Map tenantByInbound) { if (tenantByInbound.isEmpty()) return Map.of(); - return transports.selectList(new LambdaQueryWrapper() - .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) - .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) - .collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, - Function.identity(), (left, right) -> left)); + Map> grouped = transports + .findCurrentByInboundIds(List.copyOf(tenantByInbound.keySet())).stream() + .filter(item -> owns(tenantByInbound, item.inboundId(), item.accountId())) + .collect(Collectors.groupingBy(CurrentContainerTransport::inboundId)); + grouped.forEach((inboundId, rows) -> { + long bookingCount = rows.stream().map(CurrentContainerTransport::containerBookingId).distinct().count(); + if (bookingCount > 1) throw new BizException(ResultCode.CONFLICT, + "同一入库业务单元关联多个有效柜次: inboundId=" + inboundId); + }); + return grouped.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, + entry -> entry.getValue().get(0))); } private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java deleted file mode 100644 index 6db70d765..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.baiyue.firstleg.service; - -import jakarta.annotation.Resource; - -import com.baiyue.common.audit.BusinessAudit; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import org.springframework.stereotype.Service; - -@Service -public class AutomaticInboundCompletionService { - @Resource - private FbaPricingService pricing; - @Resource - private InboundWorkflowRepository workflow; - @Resource - private InboundCompletionOrchestrator completion; - - @BusinessAudit(event = "INBOUND_AUTO_COMPLETE", resourceType = "INBOUND", resourceId = "#inboundId", - description = "测量审核通过后自动计费、发送费用并完成入库", captureBefore = false) - public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId, - String operatorName, long measurementId, - int reviewedOrderVersion) { - String key = "auto-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion; - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (completed(order)) return pricing.latest(tenantId, inboundId); - - if ("WAIT_PRICING".equals(order.getProcessStatus()) && "UNPRICED".equals(order.getFeeStatus())) { - pricing.calculate(tenantId, operatorId, inboundId, null, key + "-calculate"); - order = requireOrder(tenantId, inboundId); - } - if ("FEE_CONFIRMING".equals(order.getProcessStatus()) - && "CALCULATED".equals(order.getFeeStatus())) { - pricing.confirm(tenantId, operatorId, inboundId, key + "-confirm"); - order = requireOrder(tenantId, inboundId); - } - - FeeSnapshotResponse fee = pricing.latest(tenantId, inboundId); - if ("FEE_CONFIRMING".equals(order.getProcessStatus()) - && "CONFIRMED".equals(order.getFeeStatus())) { - completion.beginSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId, - fee.feeId(), order.getVersion(), key + "-begin"); - order = requireOrder(tenantId, inboundId); - } - if ("INBOUNDING".equals(order.getProcessStatus()) - && "CONFIRMED".equals(order.getFeeStatus())) { - completion.completeSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId, - fee.feeId(), order.getVersion(), key + "-complete"); - order = requireOrder(tenantId, inboundId); - } - if (!completed(order)) { - throw new BizException(ResultCode.CONFLICT, "客服审核后的自动计费入库流程状态异常"); - } - return pricing.latest(tenantId, inboundId); - } - - private static boolean completed(InboundOrderEntity order) { - return "WAIT_SPLIT".equals(order.getProcessStatus()) - && "SENT".equals(order.getFeeStatus()); - } - - private InboundOrderEntity requireOrder(long tenantId, long inboundId) { - InboundOrderEntity order = workflow.findOwned(tenantId, inboundId); - if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - return order; - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 1e67be5d1..485ffc52b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import feign.FeignException; import org.slf4j.Logger; @@ -32,7 +33,8 @@ import java.util.Set; @Service public class CartonMeasurementService { private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class); - private static final Set DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED"); + private static final Set DEVICE_WRITABLE_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set MANUAL_COUNT_EDITABLE_STATUSES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); @@ -143,13 +145,29 @@ public class CartonMeasurementService { } // 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。 if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) { - BigDecimal totalWeight = items.stream() - .map(InboundMeasurementItemEntity::getTotalWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.CEILING); - if (positive(totalWeight)) { - inbound.setActualGrossWeightKg(totalWeight); + List confirmedWeights = repository.listWeights(tenantId, inboundId) + .stream() + .filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) + .toList(); + if (!confirmedWeights.isEmpty()) { + if (confirmedWeights.stream().anyMatch(weight -> !positive(weight.getGrossWeightKg()) + || !positive(weight.getNetWeightKg()))) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "已确认称重结果缺少有效毛重或净重,请重新确认后再修改箱数"); + } + BigDecimal confirmedGrossWeight = confirmedWeights.stream() + .map(InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + BigDecimal confirmedNetWeight = confirmedWeights.stream() + .map(InboundWeightResultEntity::getNetWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + inbound.setActualGrossWeightKg(confirmedGrossWeight); + inbound.setActualNetWeightKg(confirmedNetWeight); + } else { + inbound.setActualGrossWeightKg(null); + inbound.setActualNetWeightKg(null); } inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); @@ -206,7 +224,6 @@ public class CartonMeasurementService { item.setLengthCm(command.lengthCm()); item.setWidthCm(command.widthCm()); item.setHeightCm(command.heightCm()); - item.setUnitWeightKg(command.unitWeightKg()); item.setSourceImageFileId(imageFileId); item.setMachineNo(command.machineNo().trim()); item.setDeviceMeasuredAt(command.measuredAt()); @@ -259,13 +276,9 @@ public class CartonMeasurementService { BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY); item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm()) .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); - item.setUnitWeightKg(item.getUnitWeightKg() == null ? null - : item.getUnitWeightKg().setScale(3, RoundingMode.CEILING)); item.setUnitVolumeCbm(unitVolume); item.setVolumeRawCbm(totalRaw); item.setVolumeCbm(totalVolume); - item.setTotalWeightKg(item.getUnitWeightKg() == null ? null - : item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); item.setCalculationRuleVersion(weighted.calculationRuleVersion()); @@ -289,8 +302,6 @@ public class CartonMeasurementService { .setScale(4, RoundingMode.UNNECESSARY); item.setVolumeRawCbm(totalRaw); item.setVolumeCbm(totalVolume); - item.setTotalWeightKg(item.getUnitWeightKg() == null ? null - : item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); } @@ -398,9 +409,6 @@ public class CartonMeasurementService { if (!positive(command.lengthCm()) || !positive(command.widthCm()) || !positive(command.heightCm())) { throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0"); } - if (command.unitWeightKg() != null && !positive(command.unitWeightKg())) { - throw new BizException(ResultCode.VALIDATION_FAILED, "单箱重量必须大于0"); - } if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) { throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符"); } @@ -434,7 +442,7 @@ public class CartonMeasurementService { return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(), item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), - item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(), + item.getHeightCm(), item.getSourceImageFileId(), item.getMachineNo(), item.getDeviceMeasuredAt(), created); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 939ffc29b..a90178f9e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -2,18 +2,19 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; import com.baiyue.firstleg.entity.FeeItemEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; import com.baiyue.firstleg.repository.FeeItemRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.math.RoundingMode; import java.time.LocalDateTime; @@ -26,6 +27,8 @@ public class ContainerFeeDetailService { private final SmartLoadingRepository loading; private final FeeItemRepository feeItems; private final FinanceFeeSourceOutboxService financeOutbox; + private final WarehouseSupplierSnapshotService suppliers; + private final ContainerFeeSourceSnapshotRepository sourceSnapshots; public List list(long tenantId, long loadingPlanId) { requireOwnedPlan(tenantId, loadingPlanId); @@ -55,18 +58,19 @@ public class ContainerFeeDetailService { @Transactional public void approve(long tenantId, long loadingPlanId, long feeId, long reviewerId, String reviewerName) { - required(tenantId, loadingPlanId, feeId); + ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId); + FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection()); + refreshSupplierSnapshot(pending, item, reviewerId); if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED", reviewerId, reviewerName, null) != 1) { throw conflict("只有待审核柜次费用可以审核通过"); } ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId); - if (Boolean.TRUE.equals(approved.getSettlementIncluded())) { - String eventType = approved.getSourceVersion() > 1 - ? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED; - financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId, - approved.getSourceVersion(), LocalDateTime.now()); - } + sourceSnapshots.saveApproved(approved); + String eventType = approved.getSourceVersion() > 1 + ? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED; + financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId, + approved.getSourceVersion(), LocalDateTime.now()); } @Transactional @@ -81,19 +85,23 @@ public class ContainerFeeDetailService { @Transactional public ContainerFeeDetailEntity adjust(long tenantId, long loadingPlanId, long feeId, - int expectedVersion, long operatorId, - SaveRequest request) { + long operatorId, AdjustRequest request) { requireOwnedPlan(tenantId, loadingPlanId); ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId); - FeeItemEntity item = requireItem(request.feeItemId(), request.direction()); - apply(value, item, request, operatorId); - value.setSourceVersion(expectedVersion + 1); - value.setReviewStatus("PENDING"); - value.setReviewerId(null); - value.setReviewerName(null); - value.setReviewedAt(null); - value.setReviewReason(null); - if (repository.adjust(value, expectedVersion) != 1) { + int expectedVersion = request.expectedSourceVersion(); + var amount = request.amount().setScale(2, RoundingMode.HALF_UP); + if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1 + && "ACTIVE".equals(value.getSourceStatus()) && "PENDING".equals(value.getReviewStatus()) + && request.businessDate().equals(value.getBusinessDate()) + && amount.compareTo(value.getAmount()) == 0) { + return value; + } + if (!"ACTIVE".equals(value.getSourceStatus()) || !"APPROVED".equals(value.getReviewStatus()) + || value.getSourceVersion() == null || value.getSourceVersion() != expectedVersion) { + throw conflict("只有当前版本的已审核柜次费用可以调整"); + } + if (repository.adjust(tenantId, loadingPlanId, feeId, expectedVersion, + request.businessDate(), amount, operatorId, LocalDateTime.now()) != 1) { throw conflict("只有当前版本的已审核柜次费用可以调整"); } return required(tenantId, loadingPlanId, feeId); @@ -106,10 +114,8 @@ public class ContainerFeeDetailService { if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) { throw conflict("只有当前版本的已审核柜次费用可以作废"); } - if (Boolean.TRUE.equals(current.getSettlementIncluded())) { - financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId, - loadingPlanId, feeId, expectedVersion, LocalDateTime.now()); - } + financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId, + loadingPlanId, feeId, expectedVersion, LocalDateTime.now()); } private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) { @@ -139,28 +145,49 @@ public class ContainerFeeDetailService { return value; } - private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item, - SaveRequest request, long operatorId) { - if ("PAYABLE".equals(request.direction()) - && (request.partnerId() == null || request.partnerId() <= 0 - || !StringUtils.hasText(request.partnerName()))) { - throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效往来单位"); - } + private void apply(ContainerFeeDetailEntity value, FeeItemEntity item, + SaveRequest request, long operatorId) { value.setFeeItemId(item.getId()); value.setFeeItemCodeSnapshot(item.getFeeCode()); value.setFeeItemNameSnapshot(item.getFeeTypeName()); value.setDirection(request.direction()); - value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null); - value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName().trim() : null); + applySupplierSnapshot(value, item, request.direction(), request.supplierId()); value.setBusinessDate(request.businessDate()); value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setCurrency(request.currency()); - value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded())); value.setRemark(request.remark() == null ? null : request.remark().trim()); value.setUpdatedBy(operatorId); value.setUpdatedAt(LocalDateTime.now()); } + private void applySupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item, + String direction, Long supplierId) { + if (!"PAYABLE".equals(direction)) { + value.setSupplierId(null); + value.setSupplierName(null); + value.setSupplierCategory(null); + return; + } + if (supplierId == null || supplierId <= 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商"); + } + String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory()); + var snapshot = suppliers.fee(supplierId, category); + value.setSupplierId(snapshot.supplierId()); + value.setSupplierName(snapshot.name()); + value.setSupplierCategory(category); + } + + private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item, + long operatorId) { + applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId()); + if (repository.updateSupplierSnapshot(value.getTenantId(), value.getLoadingPlanId(), + value.getId(), value.getSupplierId(), value.getSupplierName(), + value.getSupplierCategory(), operatorId) != 1) { + throw conflict("柜次费用供应商快照已变化,请刷新后重试"); + } + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 504170b88..2733ac4a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -8,10 +8,14 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import feign.FeignException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -25,6 +29,8 @@ import java.util.List; public class ContainerOverviewService { @Resource private ContainerRepository containers; @Resource private ContainerOverviewMapper mapper; + @Resource private LoadingEvidenceFileClient evidenceFiles; + @Value("${baiyue.security.internal-service-token}") private String internalToken; public Overview overview(long id) { ContainerBookingEntity booking = required(id); @@ -69,7 +75,7 @@ public class ContainerOverviewService { if (!requestHash.equals(replay.requestHash())) throw conflict("Idempotency-Key已用于不同异常操作"); return; } - ContainerBookingEntity booking = containers.findForUpdate(id); + ContainerBookingEntity booking = containers.findActiveForUpdate(id); if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试"); replay = mapper.exceptionOperation(id, operatorId, operationId); @@ -94,18 +100,49 @@ public class ContainerOverviewService { private PdaSummary pda(ContainerOverviewMapper.PickTaskRow task) { List photos = mapper.photos(task.loadingPlanId()).stream().map(value -> new Photo(value.evidenceId(), value.tenantId(), value.inboundId(), value.evidenceType(), value.fileId(), value.fileName(), - value.status(), "/api/platform/v1/files/loading-evidence/" + value.tenantId() + '/' - + value.inboundId() + '/' + value.fileId(), value.uploadedAt())).toList(); + value.status(), "ACTIVE".equals(value.status()) + ? "/api/platform/v1/containers/" + task.containerBookingId() + "/pda/photos/" + + value.fileId() + "/download" + : null, value.uploadedAt())).toList(); var counts = new LinkedHashMap(); photos.stream().filter(photo -> "ACTIVE".equals(photo.status())) .forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum)); List photoCounts = counts.entrySet().stream() .map(entry -> new PhotoCount(entry.getKey(), entry.getValue())).toList(); + int itemCount = mapper.itemCount(task.id()); + int unresolvedItemCount = mapper.unresolvedItemCount(task.id()); + String workflowStatus = "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" + : unresolvedItemCount == 0 ? "AWAITING_REVIEW" : "IN_PROGRESS"; return new PdaSummary(task.id(), task.loadingPlanId(), task.containerBookingId(), task.taskStatus(), - task.version(), mapper.itemCount(task.id()), mapper.completedItemCount(task.id()), - mapper.unresolvedItemCount(task.id()), photoCounts, photos, mapper.pickExceptionReasons(task.id())); + workflowStatus, task.version(), itemCount, mapper.completedItemCount(task.id()), + unresolvedItemCount, photoCounts, photos, mapper.pickExceptionReasons(task.id())); } + public PhotoDownload downloadPhoto(long containerBookingId, long fileId) { + ContainerBookingEntity booking = required(containerBookingId); + var task = mapper.pickTask(booking.getId()); + if (task == null) throw new BizException(ResultCode.NOT_FOUND, "货柜尚无PDA作业任务"); + var photo = mapper.photos(task.loadingPlanId()).stream() + .filter(value -> Long.valueOf(fileId).equals(value.fileId()) && "ACTIVE".equals(value.status())) + .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "PDA照片不存在或不属于该货柜")); + final org.springframework.http.ResponseEntity response; + try { + response = evidenceFiles.download(internalToken, photo.tenantId(), photo.inboundId(), fileId); + } catch (FeignException.NotFound exception) { + throw new BizException(ResultCode.NOT_FOUND, "PDA照片文件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + byte[] content = response.getBody(); + if (!response.getStatusCode().is2xxSuccessful() || content == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA照片下载失败"); + } + MediaType contentType = response.getHeaders().getContentType(); + return new PhotoDownload(photo.fileName(), contentType == null ? MediaType.APPLICATION_OCTET_STREAM : contentType, content); + } + + public record PhotoDownload(String fileName, MediaType contentType, byte[] content) {} + private List timeline(long id, Long taskId, List exceptionHistory) { var result = new ArrayList(); mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index e9954f131..cfc7553c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -7,10 +7,14 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; +import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; 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.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; @@ -18,6 +22,12 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -34,6 +44,10 @@ public class ContainerService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; + @Resource private PriceBookQueryRepository priceBooks; + @Resource private OceanBookingRepository oceanBookings; + @Resource private WarehouseSupplierSnapshotService suppliers; + @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @@ -44,20 +58,73 @@ public class ContainerService { public ContainerBookingEntity detail(long id) { return required(id); } @Transactional - public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { - validateSchedule(request); + public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) { + var specification = ContainerSpecificationCatalog.requireMatch(request.containerType(), request.capacityCbm(), request.maxWeightKg()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); - ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); - ContainerBookingEntity value = new ContainerBookingEntity(); - value.setId(IdWorker.getId()); - value.setBookingNo("CB" + value.getId()); - copy(value, request); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); + ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream() + .filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst() + .orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); + ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId()); + value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB")); + value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE"); + value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName()); value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName()); - value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); - value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); - try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } - value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); - return value; + value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); + value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); + value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); return value; + } + + @Transactional + public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) { + ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container); + if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜"); + OceanBookingEntity booking = oceanBookings.find(request.oceanBookingId()); + if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱"); + OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId()); + if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); + long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count(); + if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); + if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); + if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) + || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) + || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType()) + || container.getCapacityCbm() == null || requirement.getCapacityCbm() == null + || container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null + || container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0 + || container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致"); + if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致"); + if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, + request.expectedVersion(), operatorId, operatorName) != 1) { + throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); + } + OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation); + return required(id); + } + + @Transactional + public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) { + ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container); + if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜"); + if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) { + throw conflict("货柜状态或版本已变化,请刷新后重试"); + } + oceanBookings.deleteAllocation(id); + return required(id); + } + + private void requireFirstLinkMutable(ContainerBookingEntity container) { + SmartLoadingStatus value = status(container); + if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING) + || repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId()) + || repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱"); + } + private void requireUnlinkMutable(ContainerBookingEntity container) { + if (status(container) != SmartLoadingStatus.WAIT_LOADING || repository.hasActiveLoadingItems(container.getId()) + || repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId()) + || repository.hasDocuments(container.getId())) throw conflict("已有排货、PDA、证据或单证事实,不能解除订舱关联"); } @Transactional @@ -69,10 +136,14 @@ public class ContainerService { throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); } validateSchedule(request.data()); + ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); - ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); + var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany()); + ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id); copy(current, request.data()); current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); + current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId()); + current.setTerminalTruckingCompany(trucking == null ? null : trucking.name()); try { if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { throw conflict("货柜状态或版本已变化,请刷新后重试"); @@ -104,23 +175,10 @@ public class ContainerService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); if (transportHistories != null) transportHistories.insert(history); ContainerBookingEntity updated = required(id); - beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName); + loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName); return updated; } - /** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */ - private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo, - long operatorId, String operatorName) { - if (containerNo == null) return; - LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId()); - if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; - // The PDA task and its item versions are stable work identities. A later transport-number - // correction only changes the booking display value and must not resynchronize the task. - if (loadingPickRepository.taskByPlan(plan.getId()) != null) return; - loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName, - smartLoadingRepository.items(plan.getId())); - } - private ContainerBookingEntity required(long id) { ContainerBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); @@ -163,16 +221,17 @@ public class ContainerService { catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); } } private static void copy(ContainerBookingEntity v, SaveRequest r) { - v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim()); + var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); + v.setContainerNo(trim(r.containerNo())); v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName())); v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); - v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim()); + v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType()); v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName())); v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName())); v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName())); v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode())); v.setDestinationWarehouseName(trim(r.destinationWarehouseName())); - v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); + v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt()); v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt()); v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt()); @@ -191,7 +250,7 @@ public class ContainerService { } private static Query normalize(Query q) { return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), - q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), + q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), q.estimatedSailingTo(), q.page(), q.size()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 32474bab7..a5f92a869 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -58,9 +58,9 @@ public class FbaPricingService { ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm())); + pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm())); RateRow rate = estimate.rate(); - BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP); + BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP); BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; @@ -81,6 +81,7 @@ public class FbaPricingService { estimate.chargeUnit(), chargeable) != 1) { throw conflict("入库单状态已变化,费用试算未保存"); } + feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); complete(token, fee.getId(), now); String nextStatus = isCompletedInbound(inbound.getProcessStatus()) ? inbound.getProcessStatus() : "FEE_CONFIRMING"; @@ -144,14 +145,18 @@ public class FbaPricingService { AdjustFeeRequest request, String idempotencyKey) { InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - if (!isCompletedInbound(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { - throw conflict("只能修改已完成入库且已发布的费用"); + if (!isCompletedInbound(inbound.getProcessStatus())) { + throw conflict("只能在待分柜阶段修改初算或已发布费用"); } InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); - if (current == null || !"SENT".equals(current.getFeeStatus())) { - throw conflict("入库单没有可修改的已发布费用"); + if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { + throw conflict("入库单没有可修改的初算或已发布费用"); } - String discriminator = current.getId() + ":" + request.subtotalAmount() + ":" + if (!request.expectedFeeVersion().equals(current.getFeeVersion())) { + throw conflict("费用版本已变化,请刷新后重试"); + } + String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" + + request.chargeableQuantity() + ":" + request.unitPrice() + ":" + request.additionalAmount() + ":" + request.reason().trim(); InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); @@ -166,30 +171,33 @@ public class FbaPricingService { adjusted.setId(null); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeVersion(nextVersion); - adjusted.setSubtotalAmount(request.subtotalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)); + adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP)); + adjusted.setConvertedQuantity(adjusted.getChargeableQuantity()); + adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice()) + .setScale(MONEY_SCALE, RoundingMode.HALF_UP)); adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)); adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount())); - adjusted.setFeeStatus("SENT"); - adjusted.setReviewStatus("NOT_REQUIRED"); + boolean published = "SENT".equals(current.getFeeStatus()); + adjusted.setFeeStatus(published ? "SENT" : "CALCULATED"); + adjusted.setReviewStatus(published ? "NOT_REQUIRED" : "NOT_SUBMITTED"); adjusted.setCalculatedBy(operatorId); adjusted.setCalculatedAt(now); - adjusted.setConfirmedBy(operatorId); - adjusted.setConfirmedAt(now); - adjusted.setSentBy(operatorId); - adjusted.setSentAt(now); + adjusted.setConfirmedBy(published ? operatorId : null); + adjusted.setConfirmedAt(published ? now : null); + adjusted.setSentBy(published ? operatorId : null); + adjusted.setSentAt(published ? now : null); adjusted.setCreatedAt(now); adjusted.setUpdatedAt(now); - if (repository.supersedeSent(tenantId, current.getId()) != 1) { + if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) { throw conflict("费用已被其他操作员修改,请刷新后重试"); } repository.insertFee(adjusted); - BigDecimal quantity = current.getChargeableQuantity(); + BigDecimal quantity = adjusted.getChargeableQuantity(); InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费", adjusted.getSubtotalAmount(), now); base.setQuantity(quantity); base.setUnit(current.getChargeUnit()); - base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO - : adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP)); + base.setUnitPrice(request.unitPrice().setScale(6, RoundingMode.HALF_UP)); base.setAutomaticFlag(false); base.setRemark(request.reason().trim()); repository.insertItem(base); @@ -200,9 +208,18 @@ public class FbaPricingService { extra.setRemark(request.reason().trim()); repository.insertItem(extra); } - feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); - financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, - tenantId, inboundId, adjusted.getId(), nextVersion, now); + if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(), + adjusted.getChargeUnit(), quantity) != 1) { + throw conflict("入库单状态已变化,费用调整未保存"); + } + if (published) { + feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); + financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, + tenantId, inboundId, adjusted.getId(), nextVersion, now); + } else { + feeDetails.materializeCalculatedFreight( + tenantId, inboundId, adjusted.getId(), operatorId, now); + } complete(token, adjusted.getId(), now); return response(tenantId, adjusted.getId(), inbound.getProcessStatus()); } @@ -223,13 +240,15 @@ public class FbaPricingService { throw conflict("当前目的地类型不支持自动计费"); } if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照"); - if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0 + if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0 || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { - throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); + throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积"); } - if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null - || !StringUtils.hasText(inbound.getChannelCode())) { - throw conflict("入库单缺少客户、起运仓或渠道匹配维度"); + if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) { + throw conflict("入库单缺少订单客户归属,不能生成对客费用"); + } + if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) { + throw conflict("入库单缺少起运仓或渠道匹配维度"); } return inbound; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java index f049883cb..6d7258f87 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java @@ -102,10 +102,14 @@ public class FeeItemService { } private static void copy(FeeItemEntity value, SaveRequest request) { + boolean payable = Boolean.TRUE.equals(request.pickupPayable()) + || Boolean.TRUE.equals(request.warehousePayable()); value.setCategory(request.category().trim()); value.setTradeScope(request.tradeScope()); value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank() ? value.getFeeCode() : request.feeCode().trim().toUpperCase()); + value.setSupplierCategory(payable + ? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null); value.setFeeTypeName(request.feeTypeName().trim()); value.setPickupPayable(request.pickupPayable()); value.setPickupReceivable(request.pickupReceivable()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java new file mode 100644 index 000000000..7b3bd965f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; +import java.util.Set; + +/** 费用项目可选供应商类别约束校验。 */ +public final class FeeItemSupplierCategory { + private static final Set ALLOWED = Set.of( + "BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE"); + + private FeeItemSupplierCategory() { + } + + public static String normalize(String supplierCategory) { + if (supplierCategory == null || supplierCategory.isBlank()) return null; + String normalized = supplierCategory.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED.contains(normalized)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效"); + } + return normalized; + } + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java index 8036c1d8c..dbe62dccc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java @@ -3,11 +3,11 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot; -import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity; import com.baiyue.firstleg.entity.FeeItemEntity; import com.baiyue.firstleg.entity.InboundFeeDetailEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; import com.baiyue.firstleg.repository.FeeItemRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,11 +21,10 @@ import java.util.HexFormat; @RequiredArgsConstructor public class FinanceFeeSourceSnapshotService { private final FeeItemRepository inboundFees; - private final ContainerFeeDetailRepository containerFees; + private final ContainerFeeSourceSnapshotRepository containerSnapshots; - public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) { - InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId); - if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId); + public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId, int sourceVersion) { + InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId, feeId, sourceVersion); if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) { throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在"); } @@ -34,43 +33,44 @@ public class FinanceFeeSourceSnapshotService { if (inbound == null || item == null) { throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整"); } - long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType()) - ? detail.getSourceFeeId() : detail.getId(); + long sourceFeeId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId(); int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion(); String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId), String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId), String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()), - nullSafe(detail.getDirection()), detail.getAmount().toPlainString(), - nullSafe(detail.getCurrency()), String.valueOf(detail.getSettlementIncluded())); + nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()), + nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()), + detail.getAmount().toPlainString(), + nullSafe(detail.getCurrency())); return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId, version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null, - detail.getTenantId(), inbound.getCustomerName(), detail.getPartnerId(), detail.getPartnerName(), item.getId(), item.getFeeCode(), + detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(), + detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(), detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(), - detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()), + detail.getPublicationStatus(), detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(), detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1); } - public FinanceFeeSourceSnapshot container(long containerId, long feeId) { - ContainerFeeDetailEntity detail = containerFees.find(containerId, feeId); + public FinanceFeeSourceSnapshot container(long containerId, long feeId, int sourceVersion) { + ContainerFeeSourceSnapshotEntity detail = containerSnapshots.find(containerId, feeId, sourceVersion); if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在"); int version = detail.getSourceVersion(); - String publicationStatus = "VOIDED".equals(detail.getSourceStatus()) ? "VOIDED" - : "APPROVED".equals(detail.getReviewStatus()) ? "PUBLISHED" : "DRAFT"; String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId), String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId), - nullSafe(detail.getFeeItemCodeSnapshot()), nullSafe(detail.getDirection()), - nullSafe(detail.getPartnerId()), nullSafe(detail.getPartnerName()), + nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()), + nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()), + nullSafe(detail.getSupplierCategory()), detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()), - String.valueOf(detail.getSettlementIncluded())); + nullSafe(detail.getReviewStatus())); return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId, version, null, null, null, containerId, detail.getContainerBookingId(), - detail.getContainerNo(), detail.getTenantId(), null, detail.getPartnerId(), - detail.getPartnerName(), detail.getFeeItemId(), - detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(), + detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(), + detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(), + detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(), detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(), - publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()), + "PUBLISHED", detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java new file mode 100644 index 000000000..d69d567cf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +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 FirstlegDocumentNumberGenerator { + 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 SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP"); + private static final long MAX_DAILY_SEQUENCE = 9_999L; + + private final JdbcTemplate jdbc; + private final Clock clock; + + @Autowired + public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) { + this(jdbc, Clock.system(BUSINESS_ZONE)); + } + + FirstlegDocumentNumberGenerator(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 firstleg document prefix: " + prefix); + } + LocalDate sequenceDate = LocalDate.now(clock); + String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); + jdbc.update( + "INSERT INTO fl_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 BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽"); + } + return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + + String.format(Locale.ROOT, "%04d", sequence); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java new file mode 100644 index 000000000..a94b2e9bd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class IamCustomerSnapshotService { + @Resource private IamCustomerSnapshotClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public IamCustomerSnapshotClient.Customer active(long customerId, String suppliedName) { + try { + var response = client.page(internalToken, 1, 2, null, "ACTIVE", customerId); + if (response == null || !response.successful() || response.data() == null + || response.data().records() == null || response.data().records().size() != 1) { + throw new BizException(ResultCode.CONFLICT, "所选客户不存在或未启用"); + } + var customer = response.data().records().get(0); + if (customer.id() != customerId || !"ACTIVE".equals(customer.status()) + || !StringUtils.hasText(customer.tenantName())) { + throw new BizException(ResultCode.CONFLICT, "所选客户不存在或未启用"); + } + if (StringUtils.hasText(suppliedName) + && !suppliedName.trim().equals(customer.tenantName())) { + throw new BizException(ResultCode.BAD_REQUEST, "客户名称与服务端主数据不一致"); + } + return customer; + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java deleted file mode 100644 index c946f2e9e..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.baiyue.firstleg.service; - -import jakarta.annotation.Resource; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundFeeEntity; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.time.LocalDateTime; - -/** - * 发送费用并完成入库的状态编排骨架。 - * - *

本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用; - * 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用 - * {@link #completeSendFeeAndInbound} 发布费用并完成入库。

- */ -@Service -public class InboundCompletionOrchestrator { - @Resource - private InboundWorkflowRepository repository; - @Resource - private InboundIdempotencyService idempotency; - @Resource - private InboundFeeDetailService feeDetails; - @Resource - private FinanceFeeSourceOutboxService financeOutbox; - - /** - * 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。 - */ - @Transactional - public void beginSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, long feeId, int expectedVersion, - String idempotencyKey) { - String canonical = inboundId + "|" + feeId + "|" + expectedVersion; - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_BEGIN", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (!"FEE_CONFIRMING".equals(order.getProcessStatus()) || !"CONFIRMED".equals(order.getFeeStatus())) { - throw new BizException(ResultCode.CONFLICT, "费用未确认或入库单不在待发送阶段"); - } - boolean released = measurementGatePassed(order, tenantId, inboundId); - InboundFeeEntity fee = repository.findConfirmedFee(tenantId, inboundId, feeId); - if (fee == null) { - throw new BizException(ResultCode.CONFLICT, "缺少已确认且不可变的费用快照"); - } - if (Boolean.TRUE.equals(fee.getExceptionRelease()) != released) { - throw new BizException(ResultCode.CONFLICT, "费用快照的异常放行标记与审核历史不一致"); - } - if (repository.countPreparedAllocations(tenantId, inboundId) <= 0) { - throw new BizException(ResultCode.CONFLICT, "尚未准备托盘和库位预占引用"); - } - if (repository.beginInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "FEE_CONFIRMING", "INBOUNDING", "SEND_FEE_BEGIN", null, operatorId, operatorName)); - idempotency.complete(context, inboundId); - } - - /** - * 任务5对接方法二:仓库确认占用成功后,原子锁定费用、完成入库并写 Outbox。 - */ - @Transactional - public void completeSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, long feeId, int expectedVersion, - String idempotencyKey) { - String canonical = inboundId + "|" + feeId + "|" + expectedVersion; - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_COMPLETE", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (!"INBOUNDING".equals(order.getProcessStatus())) { - throw new BizException(ResultCode.CONFLICT, "入库单不在入库处理中"); - } - if (repository.completeInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) { - throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确"); - } - var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, - operatorName, LocalDateTime.now()); - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "INBOUNDING", "WAIT_SPLIT", "SEND_FEE_COMPLETE", null, operatorId, operatorName)); - financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId, - inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now()); - idempotency.complete(context, inboundId); - } - - /** 任务5对接方法三:仅在仓库预占已释放后回退,不能回退已完成实物入库。 */ - @Transactional - public void rollbackSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, int expectedVersion, String reason, - String idempotencyKey) { - if (!StringUtils.hasText(reason)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "入库回退必须记录失败原因"); - } - String canonical = inboundId + "|" + expectedVersion + "|" + (reason == null ? "" : reason.trim()); - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_ROLLBACK", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - requireOrder(tenantId, inboundId); - if (repository.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "INBOUNDING", "FEE_CONFIRMING", "SEND_FEE_ROLLBACK", reason, operatorId, operatorName)); - idempotency.complete(context, inboundId); - } - - private boolean measurementGatePassed(InboundOrderEntity order, long tenantId, long inboundId) { - if ("APPROVED".equals(order.getMeasureReviewStatus())) { - return false; - } - if ("REJECTED".equals(order.getMeasureReviewStatus()) - && repository.hasExceptionRelease(tenantId, inboundId)) { - return true; - } - throw new BizException(ResultCode.CONFLICT, "测量未审核通过且无有效异常放行记录"); - } - - private InboundOrderEntity requireOrder(long tenantId, long inboundId) { - InboundOrderEntity order = repository.findOwned(tenantId, inboundId); - if (order == null) { - throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - } - return order; - } - - private static BizException concurrentConflict() { - return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试"); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 442c19d28..7c8faf3c8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -22,7 +22,6 @@ import java.util.List; /** 入库单人工费用明细应用服务。 */ @Service public class InboundFeeDetailService { - private static final long PLATFORM_TENANT_ID = 0L; private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT"; @Resource private FeeItemRepository repository; @@ -30,10 +29,27 @@ public class InboundFeeDetailService { private FbaPricingRepository pricingRepository; @Resource private FinanceFeeSourceOutboxService financeOutbox; + @Resource + private WarehouseSupplierSnapshotService suppliers; - public List list(long tenantId, long inboundId) { + @Transactional + public List list(long tenantId, long inboundId, boolean includeHistory) { requiredInbound(tenantId, inboundId); - return repository.listDetails(tenantId, inboundId); + materializeLatestFreightIfMissing(tenantId, inboundId); + return repository.listDetails(tenantId, inboundId, includeHistory); + } + + private void materializeLatestFreightIfMissing(long tenantId, long inboundId) { + InboundFeeEntity latest = pricingRepository.findLatestFee(tenantId, inboundId); + if (latest == null || repository.findBySourceFee( + tenantId, "FBA_PRICING", latest.getId()) != null) return; + LocalDateTime occurredAt = latest.getSentAt() != null ? latest.getSentAt() + : latest.getCalculatedAt(); + if ("SENT".equals(latest.getFeeStatus())) { + publishFreight(tenantId, inboundId, latest.getId(), 0L, "SYSTEM", occurredAt); + } else if ("CALCULATED".equals(latest.getFeeStatus())) { + materializeCalculatedFreight(tenantId, inboundId, latest.getId(), 0L, occurredAt); + } } public InboundFeeDetailEntity publishedFreight(long tenantId, long inboundId) { @@ -49,7 +65,7 @@ public class InboundFeeDetailService { long operatorId, String operatorName, LocalDateTime publishedAt) { InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); - if (existing != null) return existing; + if (existing != null && "PUBLISHED".equals(existing.getPublicationStatus())) return existing; InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId); if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) { @@ -59,7 +75,15 @@ public class InboundFeeDetailService { throw conflict("只有已发送状态的计费快照才能生成客户运费明细"); } LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt; - FeeItemEntity feeItem = requiredFreightItem(operatorId, operatorName); + if (existing != null) { + repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now); + if (repository.publishCalculatedFreight( + tenantId, feeId, operatorId, operatorName, now) != 1) { + throw conflict("初算费用明细状态已变化,请刷新后重试"); + } + return repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); + } + FeeItemEntity feeItem = requiredFreightItem(); InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId); repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now); @@ -108,7 +132,6 @@ public class InboundFeeDetailService { detail.setPublishedByName(operatorName); detail.setPublishedAt(now); detail.setInboundedAt(inbound.getInboundedAt()); - detail.setSettlementIncluded(true); detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId); try { @@ -122,6 +145,69 @@ public class InboundFeeDetailService { } } + /** 初算成功后生成后台可见的草稿运费明细,不发布 Finance。 */ + @Transactional + public InboundFeeDetailEntity materializeCalculatedFreight(long tenantId, long inboundId, long feeId, + long operatorId, LocalDateTime calculatedAt) { + InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId); + if (existing != null) return existing; + InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); + InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId); + if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) { + throw new BizException(ResultCode.NOT_FOUND, "待展示的入库计费快照不存在"); + } + if (!"CALCULATED".equals(fee.getFeeStatus())) { + throw conflict("只有初算状态的计费快照可生成草稿运费明细"); + } + LocalDateTime now = calculatedAt == null ? LocalDateTime.now() : calculatedAt; + repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now); + InboundFeeDetailEntity detail = freightDetail( + tenantId, inboundId, operatorId, null, now, inbound, fee, requiredFreightItem(), false); + try { + repository.insertDetail(detail); + return detail; + } catch (DuplicateKeyException exception) { + InboundFeeDetailEntity concurrent = repository.findBySourceFee( + tenantId, "FBA_PRICING", feeId); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private InboundFeeDetailEntity freightDetail(long tenantId, long inboundId, long operatorId, + String operatorName, LocalDateTime now, + InboundOrderEntity inbound, InboundFeeEntity fee, + FeeItemEntity feeItem, boolean published) { + InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, fee.getId()); + InboundFeeDetailEntity detail = new InboundFeeDetailEntity(); + detail.setTenantId(tenantId); detail.setInboundId(inboundId); detail.setFeeItemId(feeItem.getId()); + detail.setSourceType("FBA_PRICING"); detail.setSourceFeeId(fee.getId()); + detail.setSourceFeeVersion(fee.getFeeVersion()); detail.setSourceFeeNo(fee.getFeeNo()); + detail.setInboundCode(inbound.getInboundCode()); detail.setDirection("RECEIVABLE"); + detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(inbound.getCustomerId()); + detail.setCustomerName(inbound.getCustomerName()); detail.setInvoiceTitle(inbound.getCompanyName()); + detail.setFeeCategorySnapshot(feeItem.getCategory()); detail.setTradeScopeSnapshot(feeItem.getTradeScope()); + detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName()); detail.setChannelCode(inbound.getChannelCode()); + detail.setChannelName(inbound.getChannelName()); detail.setDestinationCode(inbound.getDestinationCode()); + detail.setDestinationName(inbound.getDestinationName()); detail.setPackageCount(inbound.getTotalPackageCount()); + detail.setActualGrossWeightKg(fee.getActualWeightKg()); detail.setActualVolumeCbm(fee.getActualVolumeCbm()); + detail.setVolumeWeightKg(inbound.getVolumeWeightKg()); + detail.setChargeableWeightKg("KG".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableWeightKg()); + detail.setChargeableVolumeCbm("CBM".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableVolumeCbm()); + detail.setChargeableQuantity(fee.getChargeableQuantity()); detail.setChargeUnit(fee.getChargeUnit()); + detail.setUnitPrice(baseItem == null ? effectiveUnitPrice(fee) : baseItem.getUnitPrice()); + detail.setCurrency(fee.getCurrency()); detail.setAmount(fee.getTotalAmount().setScale(2, RoundingMode.HALF_UP)); + detail.setRemark("来源费用单:" + fee.getFeeNo()); + detail.setReviewStatus(published ? "APPROVED" : "PENDING"); + detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT"); + detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId); + if (published) { + detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now); + detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now); + } + return detail; + } + @Transactional public InboundFeeDetailEntity create(long tenantId, long inboundId, long operatorId, SaveRequest request) { @@ -167,7 +253,9 @@ public class InboundFeeDetailService { @Transactional public void approve(long tenantId, long inboundId, long detailId, long reviewerId, String reviewerName) { - required(tenantId, inboundId, detailId); + InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId); + FeeItemEntity item = requiredActiveItem(pending.getFeeItemId()); + refreshSupplierSnapshot(pending, item, reviewerId); if (repository.review(tenantId, inboundId, detailId, "APPROVED", reviewerId, reviewerName, null) != 1) { throw conflict("只有待审核费用明细可以审核通过"); @@ -180,7 +268,7 @@ public class InboundFeeDetailService { public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId, int expectedSourceVersion, long operatorId, SaveRequest request) { - InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId); if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) { throw conflict("只有审核通过且未作废的费用明细可以调整"); } @@ -189,38 +277,41 @@ public class InboundFeeDetailService { } InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); FeeItemEntity item = requiredActiveItem(request.feeItemId()); - apply(value, inbound, item, request, operatorId); - value.setSourceType("MANUAL"); - value.setSourceFeeVersion(expectedSourceVersion + 1); - value.setReviewStatus("PENDING"); - value.setReviewerId(null); - value.setReviewerName(null); - value.setReviewedAt(null); - value.setReviewReason(null); - value.setPublicationStatus("DRAFT"); - if (repository.adjustApproved(value, expectedSourceVersion) != 1) { + LocalDateTime now = LocalDateTime.now(); + if (repository.supersedeApprovedManual(tenantId, inboundId, detailId, + expectedSourceVersion, operatorId, now) != 1) { throw conflict("费用明细已被其他人调整,请刷新后重试"); } - return required(tenantId, inboundId, detailId); + InboundFeeDetailEntity next = new InboundFeeDetailEntity(); + next.setTenantId(tenantId); + next.setInboundId(inboundId); + next.setSourceType("MANUAL"); + next.setSourceFeeId(value.getSourceFeeId() == null ? value.getId() : value.getSourceFeeId()); + next.setSourceFeeVersion(expectedSourceVersion + 1); + next.setSourceFeeNo(value.getSourceFeeNo()); + next.setCreatedBy(operatorId); + apply(next, inbound, item, request, operatorId); + next.setReviewStatus("PENDING"); + next.setPublicationStatus("DRAFT"); + repository.insertDetail(next); + return next; } @Transactional public void voidApproved(long tenantId, long inboundId, long detailId, int expectedSourceVersion, long operatorId) { - InboundFeeDetailEntity current = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity current = requiredManual(tenantId, inboundId, detailId); if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) { throw conflict("只有当前版本的已审核费用明细可以作废"); } - if (Boolean.TRUE.equals(current.getSettlementIncluded())) { - current.setPublicationStatus("VOIDED"); - publishFinanceSource(current, "VOIDED", LocalDateTime.now()); - } + current.setPublicationStatus("VOIDED"); + publishFinanceSource(current, "VOIDED", LocalDateTime.now()); } @Transactional public void reject(long tenantId, long inboundId, long detailId, long reviewerId, String reviewerName, String reason) { - required(tenantId, inboundId, detailId); + requiredManual(tenantId, inboundId, detailId); if (repository.review(tenantId, inboundId, detailId, "REJECTED", reviewerId, reviewerName, reason.trim()) != 1) { throw conflict("只有待审核费用明细可以驳回"); @@ -240,42 +331,11 @@ public class InboundFeeDetailService { return value; } - private FeeItemEntity requiredFreightItem(long operatorId, String operatorName) { + private FeeItemEntity requiredFreightItem() { FeeItemEntity item = repository.findItemByCode(FREIGHT_FEE_CODE); - if (item != null) { - if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); - return item; - } - FeeItemEntity created = new FeeItemEntity(); - created.setTenantId(PLATFORM_TENANT_ID); - created.setCategory("头程运费"); - created.setTradeScope("INTERNATIONAL"); - created.setFeeCode(FREIGHT_FEE_CODE); - created.setFeeTypeName("FBA头程运费"); - created.setPickupPayable(false); - created.setPickupReceivable(false); - created.setWarehousePayable(false); - created.setWarehouseReceivable(true); - created.setAllocationFee(false); - created.setDescription("系统计费发布时自动维护,不允许用于应付费用"); - created.setStatus("ACTIVE"); - created.setCreatedBy(operatorId); - created.setUpdatedBy(operatorId); - created.setCreatedByName(operatorName); - created.setUpdatedByName(operatorName); - try { - repository.insertItem(created); - return created; - } catch (DuplicateKeyException exception) { - FeeItemEntity concurrent = repository.findItemByCode(FREIGHT_FEE_CODE); - if (concurrent != null) { - if (!"ACTIVE".equals(concurrent.getStatus())) { - throw conflict("系统费用项目FBA头程运费已停用"); - } - return concurrent; - } - throw exception; - } + if (item == null) throw conflict("系统费用项目FBA头程运费未初始化"); + if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); + return item; } private static java.math.BigDecimal effectiveUnitPrice(InboundFeeEntity fee) { @@ -301,19 +361,26 @@ public class InboundFeeDetailService { } private InboundFeeDetailEntity requiredEditable(long tenantId, long inboundId, long detailId) { - InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId); if ("APPROVED".equals(value.getReviewStatus())) throw conflict("审核通过的费用明细不能修改或删除"); return value; } - private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, - FeeItemEntity item, SaveRequest request, long operatorId) { + private InboundFeeDetailEntity requiredManual(long tenantId, long inboundId, long detailId) { + InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + if ("FBA_PRICING".equals(value.getSourceType())) { + throw conflict("自动头程运费明细为只读派生,请在头程费用中调整或发布"); + } + return value; + } + + private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, + FeeItemEntity item, SaveRequest request, long operatorId) { ensureWarehouseApplicable(item, request.direction()); value.setFeeItemId(item.getId()); value.setInboundCode(inbound.getInboundCode()); value.setDirection(request.direction()); - value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null); - value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName() : null); + applySupplierSnapshot(value, item, request.direction(), request.supplierId()); value.setBusinessDate(request.businessDate()); value.setFeeCategorySnapshot(item.getCategory()); value.setTradeScopeSnapshot(item.getTradeScope()); @@ -323,7 +390,8 @@ public class InboundFeeDetailService { value.setDestinationCode(inbound.getDestinationCode()); value.setDestinationName(inbound.getDestinationName()); value.setPackageCount(inbound.getTotalPackageCount()); - value.setActualGrossWeightKg(inbound.getActualGrossWeightKg()); + // 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。 + value.setActualGrossWeightKg(inbound.getActualNetWeightKg()); value.setActualVolumeCbm(inbound.getActualVolumeCbm()); value.setVolumeWeightKg(inbound.getVolumeWeightKg()); value.setChargeableWeightKg(inbound.getChargeableWeightKg()); @@ -331,24 +399,50 @@ public class InboundFeeDetailService { value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP)); value.setCurrency(request.currency()); value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); - value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded())); value.setRemark(request.remark() == null ? null : request.remark().trim()); value.setUpdatedBy(operatorId); } + private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item, + String direction, Long supplierId) { + if (!"PAYABLE".equals(direction)) { + value.setSupplierId(null); + value.setSupplierName(null); + value.setSupplierCategory(null); + return; + } + if (supplierId == null || supplierId <= 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商"); + } + String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory()); + var snapshot = suppliers.fee(supplierId, category); + value.setSupplierId(snapshot.supplierId()); + value.setSupplierName(snapshot.name()); + value.setSupplierCategory(category); + } + + private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item, + long operatorId) { + applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId()); + value.setUpdatedBy(operatorId); + if (repository.updateDetail(value) != 1) { + throw conflict("费用明细供应商快照已变化,请刷新后重试"); + } + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private void publishFinanceSource(InboundFeeDetailEntity detail, String status, LocalDateTime occurredAt) { - if (!Boolean.TRUE.equals(detail.getSettlementIncluded())) return; if ("FBA_PRICING".equals(detail.getSourceType())) return; int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion(); String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED : version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED; + long sourceId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId(); financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(), - detail.getId(), version, occurredAt); + sourceId, version, occurredAt); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java index 7b1e7e8c3..594ddf8a6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -259,13 +259,9 @@ public class InboundMeasurementOpenApiService { item.setHeightCm(source.heightCm()); item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm()) .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); - item.setUnitWeightKg(source.unitWeightKg() == null ? null - : source.unitWeightKg().setScale(3, RoundingMode.CEILING)); item.setUnitVolumeCbm(unitVolume); item.setVolumeRawCbm(totalRaw); item.setVolumeCbm(totalVolume); - item.setTotalWeightKg(source.unitWeightKg() == null ? null - : source.unitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); item.setCalculationRuleVersion(CALCULATION_RULE_VERSION); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index ed3aa7aca..aa46edbec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -57,6 +58,8 @@ public class InboundMeasurementService { private String internalToken; @Resource private MeasurementImageClient imageClient; + @Resource + private WarehousePalletLocationClient warehousePallets; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); @@ -65,7 +68,19 @@ public class InboundMeasurementService { ? List.of() : repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inboundId); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId); - return toDetail(inbound, measurement, items, weights, review); + OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement) + ? loadDeclaration(inbound) : null; + return toDetail(inbound, measurement, items, weights, review, declaration); + } + + public List weightPalletOptions( + long tenantId, long inboundId) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + return loadPalletSnapshots(inbound).stream() + .map(snapshot -> new InboundMeasurementResponses.PalletSnapshot( + snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(), + snapshot.capacityCbm(), snapshot.tareWeightKg())) + .toList(); } @Transactional @@ -74,14 +89,26 @@ public class InboundMeasurementService { InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); validateInboundDetails(tenantId, inbound, request.items()); + InboundMeasurementEntity existing = request.measurementId() == null ? null + : repository.findMeasurement(tenantId, inboundId, request.measurementId()); + OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound); + String actualGoodsName = StringUtils.hasText(request.actualGoodsName()) ? request.actualGoodsName().trim() + : existing != null && StringUtils.hasText(existing.getActualGoodsName()) + ? existing.getActualGoodsName() : defaultGoodsName(declaration); MeasurementBatchCommand command = new MeasurementBatchCommand( tenantId, inboundId, request.measurementId(), request.version(), MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, - LocalDateTime.now(), null, request.originalDocumentNo(), - request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(), + LocalDateTime.now(), null, + existing != null && StringUtils.hasText(existing.getOriginalDocumentNo()) + ? existing.getOriginalDocumentNo() : declaration.referenceId(), + existing != null && existing.getOriginalGrossWeightKg() != null + ? existing.getOriginalGrossWeightKg() : declaration.originalGrossWeightKg(), + existing != null && existing.getOriginalVolumeCbm() != null + ? existing.getOriginalVolumeCbm() : declaration.originalVolumeCbm(), + actualGoodsName, operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item( item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), - item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), + item.widthCm(), item.heightCm(), item.intelligentImageFileId(), item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), item.remark())).toList()); InboundMeasurementEntity saved = batchService.saveBatch(command); @@ -103,7 +130,7 @@ public class InboundMeasurementService { } List items = repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inboundId); - BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); + ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); if (!alreadyAdvanced) { requireConfirmedPackageTotal(inbound, items); @@ -121,9 +148,9 @@ public class InboundMeasurementService { if (measurement.getTotalPackageCount() != null) { inbound.setTotalPackageCount(measurement.getTotalPackageCount()); } - if (confirmedWeight != null) { - inbound.setActualGrossWeightKg(confirmedWeight); - } + inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg()); + inbound.setActualNetWeightKg(confirmedTotals.netWeightKg()); + inbound.setPalletCount(confirmedTotals.palletCount()); if (measurement.getTotalVolumeCbm() != null) { inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); } @@ -144,14 +171,26 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.CreateWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightEditableInbound(inbound); if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } + WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream() + .filter(snapshot -> snapshot.palletId() == request.palletId()) + .findFirst() + .orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED, + "所选托盘不属于当前入库仓库或已被删除")); + validatePalletSnapshot(pallet); String unit = rawOcrUnit(request.ocrWeightUnit()); InboundWeightResultEntity weight = new InboundWeightResultEntity(); weight.setTenantId(tenantId); weight.setInboundId(inboundId); weight.setFileId(request.fileId()); + weight.setPalletId(pallet.palletId()); + weight.setPalletCode(pallet.palletCode().trim()); + weight.setPalletType(pallet.palletType().trim()); + weight.setPalletCapacityCbm(pallet.capacityCbm().setScale(4, RoundingMode.HALF_UP)); + weight.setPalletTareWeightKg(pallet.tareWeightKg().setScale(3, RoundingMode.HALF_UP)); weight.setOcrProvider(request.ocrProvider()); weight.setOcrRawText(request.ocrRawText()); weight.setOcrWeightValue(request.ocrWeightValue()); @@ -164,6 +203,7 @@ public class InboundMeasurementService { weight.setDeleted(false); weight.setVersion(0); repository.insertWeight(weight); + recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -172,10 +212,12 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.CorrectWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightEditableInbound(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); + recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -192,7 +234,8 @@ public class InboundMeasurementService { weight.setAdjustmentReason(adjustmentReason.trim()); weight.setAdjustedBy(operatorId); weight.setAdjustedAt(LocalDateTime.now()); - weight.setFinalWeightKg(null); + weight.setGrossWeightKg(null); + weight.setNetWeightKg(null); weight.setConfirmedBy(null); weight.setConfirmedAt(null); weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM); @@ -203,7 +246,7 @@ public class InboundMeasurementService { int correctedVersion = expectedVersion + 1; weight.setVersion(correctedVersion); insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit, - adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(), + adjustedWeightValue, adjustedUnit, null, null, adjustmentReason.trim(), operatorId, operatorName); return correctedVersion; } @@ -213,6 +256,7 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.ConfirmWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightConfirmableInbound(inbound, request.hasAdjustment()); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); int currentVersion = request.version(); @@ -232,8 +276,15 @@ public class InboundMeasurementService { throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位"); } String unit = normalizeUnit(rawUnit); - BigDecimal finalKg = requireConfirmableKg(value, unit); - weight.setFinalWeightKg(finalKg); + BigDecimal grossWeightKg = requireConfirmableKg(value, unit); + BigDecimal netWeightKg = grossWeightKg.subtract(weight.getPalletTareWeightKg()) + .setScale(3, RoundingMode.HALF_UP); + if (!isPositive(netWeightKg)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "称重毛重必须大于托盘皮重"); + } + weight.setGrossWeightKg(grossWeightKg); + weight.setNetWeightKg(netWeightKg); weight.setRecognitionStatus(WEIGHT_CONFIRMED); weight.setConfirmedBy(operatorId); weight.setConfirmedAt(LocalDateTime.now()); @@ -244,26 +295,22 @@ public class InboundMeasurementService { int confirmedVersion = currentVersion + 1; weight.setVersion(confirmedVersion); insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, - finalKg, null, operatorId, operatorName); + grossWeightKg, netWeightKg, null, operatorId, operatorName); recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @Transactional public void deleteWeightResult(long tenantId, long inboundId, long resultId, - int version, long operatorId) { + int version, long operatorId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { - throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片"); - } + requireWeightEditableInbound(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(version, weight.getVersion()); if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) { throw concurrentModification(); } - if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { - recalculateConfirmedWeight(inbound, operatorId); - } + recalculateConfirmedWeight(inbound, operatorId); try { ApiResponse response = imageClient.delete( internalToken, operatorId, tenantId, inboundId, weight.getFileId()); @@ -279,15 +326,10 @@ public class InboundMeasurementService { } private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) { - List confirmedWeights = repository - .listWeights(inbound.getTenantId(), inbound.getId()).stream() - .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .toList(); - BigDecimal total = confirmedWeights.stream() - .map(InboundWeightResultEntity::getFinalWeightKg) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.HALF_UP); - inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total); + ConfirmedWeightTotals totals = confirmedWeightTotals( + repository.listWeights(inbound.getTenantId(), inbound.getId())); + inbound.setActualGrossWeightKg(totals.grossWeightKg()); + inbound.setActualNetWeightKg(totals.netWeightKg()); if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } @@ -297,13 +339,31 @@ public class InboundMeasurementService { } } - private BigDecimal confirmedWeightForSubmission(List weights) { - BigDecimal total = weights.stream() + private ConfirmedWeightTotals confirmedWeightTotals(List weights) { + List confirmed = weights.stream() .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .map(InboundWeightResultEntity::getFinalWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); - return isPositive(total) ? total : null; + .toList(); + if (confirmed.isEmpty()) { + return new ConfirmedWeightTotals(null, null, 0); + } + if (confirmed.stream().anyMatch(weight -> !isPositive(weight.getGrossWeightKg()) + || !isPositive(weight.getNetWeightKg()))) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "已确认称重结果缺少有效毛重或净重,请重新确认后再继续"); + } + BigDecimal grossWeightKg = confirmed.stream() + .map(InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + BigDecimal netWeightKg = confirmed.stream() + .map(InboundWeightResultEntity::getNetWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + return new ConfirmedWeightTotals(grossWeightKg, netWeightKg, confirmed.size()); + } + + private record ConfirmedWeightTotals( + BigDecimal grossWeightKg, BigDecimal netWeightKg, int palletCount) { } private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) { @@ -313,9 +373,27 @@ public class InboundMeasurementService { } } + private void requireWeightEditableInbound(InboundOrderEntity inbound) { + if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许修改称重结果"); + } + } + + private void requireWeightConfirmableInbound(InboundOrderEntity inbound, boolean hasAdjustment) { + if (WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + return; + } + if ("MEASURE_PENDING".equals(inbound.getProcessStatus()) && hasAdjustment) { + return; + } + throw new BizException(ResultCode.CONFLICT, + "当前入库状态不允许确认称重结果,待审核状态仅允许人工修正并确认"); + } + private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, BigDecimal beforeValue, String beforeUnit, - BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg, + BigDecimal afterValue, String afterUnit, + BigDecimal grossWeightKg, BigDecimal netWeightKg, String reason, long operatorId, String operatorName) { InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity(); revision.setTenantId(weight.getTenantId()); @@ -327,7 +405,8 @@ public class InboundMeasurementService { revision.setBeforeWeightUnit(beforeUnit); revision.setAfterWeightValue(afterValue); revision.setAfterWeightUnit(afterUnit); - revision.setFinalWeightKg(finalWeightKg); + revision.setGrossWeightKg(grossWeightKg); + revision.setNetWeightKg(netWeightKg); revision.setReason(reason); revision.setOperatorId(operatorId); revision.setOperatorName(operatorName); @@ -338,7 +417,7 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Detail toDetail( InboundOrderEntity inbound, InboundMeasurementEntity measurement, List items, List weights, - MeasureReviewRecordEntity review) { + MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) { return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -349,23 +428,67 @@ public class InboundMeasurementService { measurement == null ? null : measurement.getStatus(), inbound.getVersion(), measurement == null ? null : measurement.getVersion(), - measurement == null ? null : measurement.getOriginalDocumentNo(), - measurement == null ? null : measurement.getOriginalGrossWeightKg(), - measurement == null ? null : measurement.getOriginalVolumeCbm(), + measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) + ? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(), + measurement != null && measurement.getOriginalGrossWeightKg() != null + ? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(), + measurement != null && measurement.getOriginalVolumeCbm() != null + ? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(), inbound.getTotalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), inbound.getActualGrossWeightKg(), + inbound.getActualNetWeightKg(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), - measurement == null ? null : measurement.getActualGoodsName(), + measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) + ? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(), items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList()); } + private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) { + return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo()) + || measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null + || !StringUtils.hasText(measurement.getActualGoodsName()); + } + + private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) { + if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,无法读取客户申报数据"); + } + try { + ApiResponse response = orderCargoClient + .measurementDeclaration(internalToken, inbound.getTenantId(), inbound.getOrderId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "客户订单申报数据查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + if (exception.status() == 400) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "订单服务申报快照契约未就绪,请同步部署 order-service 与 firstleg-service"); + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法读取客户申报数据"); + } + } + + private String defaultGoodsName(OrderCargoClient.MeasurementDeclarationSnapshot declaration) { + String value = declaration.defaultActualGoodsName(); + if (!StringUtils.hasText(value)) return null; + if (value.length() > 255) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "客户货物品名汇总超过255字符,请填写精简后的实际品名"); + } + return value; + } + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { return new InboundMeasurementResponses.Item( item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), - item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(), + item.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), @@ -392,13 +515,46 @@ public class InboundMeasurementService { private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) { return new InboundMeasurementResponses.WeightResult( - weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(), + weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(), + weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(), + weight.getOcrProvider(), weight.getOcrRawText(), weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(), - weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(), + weight.getGrossWeightKg(), + weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), + weight.getNetWeightKg(), weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(), weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); } + private List loadPalletSnapshots( + InboundOrderEntity inbound) { + if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘"); + } + try { + ApiResponse> response = + warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "托盘快照查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照"); + } + } + + private void validatePalletSnapshot(WarehousePalletLocationClient.PalletSnapshot snapshot) { + if (snapshot.palletId() <= 0 || !StringUtils.hasText(snapshot.palletCode()) + || !StringUtils.hasText(snapshot.palletType()) || !isPositive(snapshot.capacityCbm()) + || snapshot.tareWeightKg() == null + || snapshot.tareWeightKg().compareTo(BigDecimal.ZERO) < 0) { + throw new BizException(ResultCode.CONFLICT, "仓库服务返回的托盘快照不完整"); + } + } + private InboundOrderEntity requireInbound(long tenantId, long inboundId) { InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 7a1ba5a1a..193940b28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -9,8 +9,10 @@ import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -27,6 +29,8 @@ public class InboundOrderCreationService { @Resource private InboundStatusHistoryMapper histories; @Resource + private InboundPickupSnapshotMapper pickupSnapshots; + @Resource private FulfillmentTaskProvisioningService fulfillmentTasks; @Transactional @@ -37,6 +41,7 @@ public class InboundOrderCreationService { FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now()); linkTask(existing, task.getId()); upgradePreparing(existing, command); + savePickupSnapshot(existing.getId(), command, LocalDateTime.now()); return inboundId; } @@ -87,6 +92,7 @@ public class InboundOrderCreationService { inbound.setVersion(0); try { orders.insert(inbound); + savePickupSnapshot(inbound.getId(), command, now); } catch (DuplicateKeyException duplicate) { InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true); if (winner == null) { @@ -119,6 +125,7 @@ public class InboundOrderCreationService { if (existing != null) { long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); refreshPreparing(existing, command); + savePickupSnapshot(existing.getId(), command, LocalDateTime.now()); return inboundId; } @@ -126,6 +133,7 @@ public class InboundOrderCreationService { InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT"); try { orders.insert(inbound); + savePickupSnapshot(inbound.getId(), command, now); } catch (DuplicateKeyException duplicate) { InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true); if (winner == null) { @@ -248,6 +256,27 @@ public class InboundOrderCreationService { histories.insert(history); } + private void savePickupSnapshot(long inboundId, OrderApprovedInboundCommand command, LocalDateTime now) { + InboundPickupSnapshotEntity existing = pickupSnapshots.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(InboundPickupSnapshotEntity::getTenantId, command.tenantId()) + .eq(InboundPickupSnapshotEntity::getInboundId, inboundId)); + if (existing == null) { + InboundPickupSnapshotEntity snapshot = new InboundPickupSnapshotEntity(); + snapshot.setId(IdWorker.getId()); + snapshot.setTenantId(command.tenantId()); + snapshot.setInboundId(inboundId); + snapshot.setPickupMethod(command.pickupMethod()); + snapshot.setPickupAt(command.requestedPickupAt()); + snapshot.setCreatedAt(now); + pickupSnapshots.insert(snapshot); + } else if ("PREPARING".equals(findByOrder(command.tenantId(), command.orderId(), false).getProcessStatus())) { + existing.setPickupMethod(command.pickupMethod()); + existing.setPickupAt(command.requestedPickupAt()); + pickupSnapshots.updateById(existing); + } + } + private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) { var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(InboundOrderEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java new file mode 100644 index 000000000..27e4dd346 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.service; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** Best-effort initial pricing; pricing availability never gates inbound workflow. */ +@Service +public class InitialInboundPricingService { + private static final Logger log = LoggerFactory.getLogger(InitialInboundPricingService.class); + @Resource + private FbaPricingService pricing; + + public MeasurementApprovalResponse tryCalculate(long tenantId, long operatorId, long inboundId, + long measurementId, int reviewedOrderVersion) { + String key = "initial-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion; + try { + FeeSnapshotResponse fee = pricing.calculate(tenantId, operatorId, inboundId, null, key); + return new MeasurementApprovalResponse(inboundId, measurementId, "WAIT_SPLIT", + fee.feeStatus(), true, null, fee); + } catch (BizException exception) { + log.info("入库初算未生成,主流程继续 tenantId={}, inboundId={}, reason={}", + tenantId, inboundId, exception.getMessage()); + return pending(inboundId, measurementId, exception.getMessage()); + } catch (RuntimeException exception) { + log.warn("入库初算异常,主流程继续 tenantId={}, inboundId={}", tenantId, inboundId, exception); + return pending(inboundId, measurementId, "初算暂未生成,请人工维护费用"); + } + } + + private static MeasurementApprovalResponse pending(long inboundId, long measurementId, String message) { + return new MeasurementApprovalResponse(inboundId, measurementId, "WAIT_SPLIT", "UNPRICED", + false, message, null); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index b913c1084..3d365eebe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -8,7 +8,7 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; @@ -35,7 +35,7 @@ import java.util.Objects; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { - private static final long HANDOVER_VERSION = 1L; + private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; @@ -54,39 +54,40 @@ public class LoadingCompletedOutboxService { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); - List orders = byInbound.values().stream() + List orders = byInbound.values().stream() .map(value -> order(value, palletRows.stream() .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList(); - var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()), + var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); JsonNode payloadNode = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String traceId = EventIdentifiers.newUuid(); - return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV1.Order order(List values, List palletRows) { + private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); - List items = sorted.stream().map(row -> - new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), + List items = sorted.stream().map(row -> + new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), - row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark())) + row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), + row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark())) .toList(); String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; - List pallets = dropship(first.getBusinessType()) + List pallets = dropship(first.getBusinessType()) ? palletSnapshots(sorted, palletRows) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); } - return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()), + return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), @@ -95,7 +96,7 @@ public class LoadingCompletedOutboxService { sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); } - private List palletSnapshots(List values, List source) { + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), @@ -117,11 +118,11 @@ public class LoadingCompletedOutboxService { DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new)); Map remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap( DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new)); - List result = new ArrayList<>(); + List result = new ArrayList<>(); for (List palletRows : byPallet.values()) { PalletRow first = palletRows.get(0); int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount(); - List sku = new ArrayList<>(); + List sku = new ArrayList<>(); for (DetailSnapshot detail : details.values()) { int remaining = remainingPackages.getOrDefault(detail.detailId(), 0); if (capacity <= 0 || remaining <= 0) continue; @@ -132,13 +133,13 @@ public class LoadingCompletedOutboxService { int markEnd = Math.min(detail.marks().size(), markStart + count); List marks = new ArrayList<>(detail.marks().subList(markStart, markEnd)); if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致"); - sku.add(new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(detail.skuCode(), + sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.skuCode(), detail.productName(), count, totalQuantity, marks)); remainingPackages.put(detail.detailId(), remaining - count); remainingMarks.put(detail.detailId(), markEnd); capacity -= count; } - if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV1.PalletSnapshot( + if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot( first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku)); } if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index c70c01d36..2b287f49d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -10,6 +10,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; @@ -18,9 +20,11 @@ import com.baiyue.firstleg.client.LoadingDocumentFileClient; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -28,6 +32,7 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @@ -38,6 +43,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -57,6 +63,9 @@ public class LoadingListService { @Resource private LoadingPickRepository pickRepository; @Resource + @Lazy + private LoadingPickService loadingPickService; + @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource private ObjectMapper objectMapper; @@ -71,13 +80,13 @@ public class LoadingListService { if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) { throw badRequest("预装清单状态只能是 EDITING 或 LOADING"); } - return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } public PageResult confirmedPage(ConfirmedQuery query) { validateRange(query.createdFrom(), query.createdTo()); - return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(), + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } @@ -420,9 +429,51 @@ public class LoadingListService { "containerNo", containerNo, "voyageNo", voyageNo, "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), trim(request.operationReason())); + loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName); return repository.preloadDetail(requirePlan(planId)); } + @Transactional + @BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "原子确认装柜计划全部预装票据") + public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName, + String idempotencyKey, + ConfirmPreloadRequest request) { + String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()); + String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey); + HistoryRow replay = repository.history(operationId); + if (replay != null) return confirmationReplay(replay, requestHash); + + AggregateRow before = requirePlan(planId); + List links = loadingPickService.requirePreloadConfirmationReady(planId); + if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + Map preloads = new LinkedHashMap<>(); + links.forEach(link -> preloads.putIfAbsent(link.preloadPlanId(), link)); + for (Link link : preloads.values()) { + if (repository.confirmLinkedPreload(link.preloadPlanId(), link.preloadStatus(), + link.preloadVersion(), operatorId, trim(operatorName)) != 1) { + throw conflict("关联预装票据已被其他操作修改,请刷新后重试"); + } + } + links.stream().map(Link::containerBookingId).filter(java.util.Objects::nonNull).distinct() + .forEach(containerBookingId -> loadingPickService.beginIfReady( + containerBookingId, operatorId, operatorName)); + + AggregateRow after = requirePlan(planId); + PreloadDetail detail = repository.preloadDetail(after); + PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId, + after.getContainerBookingId(), detail.summary().readiness().confirmationStatus(), + after.getVersion(), detail.summary().readiness()); + repository.appendHistory(planId, operationId, "CONFIRM_PRELOAD", + before.getPlanStatus(), after.getPlanStatus(), json(snapshot(before)), + json(values("requestHash", requestHash, "response", response)), + operatorId, trim(operatorName), null); + return response; + } + /** * PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。 */ @@ -457,6 +508,14 @@ public class LoadingListService { json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", "version", before.getVersion() + 1)), operatorId, trim(operatorName), "PDA已人工确认装柜完成"); + } + + /** + * Publishes the cross-region handover only after every inbound projection has reached LOADED. + * The PDA completion orchestrator invokes this after finalizing all fulfillment slices. + */ + @Transactional + public void publishCompletedEvent(long planId) { loadingCompletedOutboxService.publish(planId); } @@ -481,6 +540,9 @@ public class LoadingListService { if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) { throw conflict("仅预装柜中或拣货中可修改集装箱号与航次"); } + if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) { + throw conflict("仅已推送的预装清单可维护运输字段"); + } } private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { @@ -500,11 +562,34 @@ public class LoadingListService { return "PL-" + sha256(operation + '|' + planId + '|' + normalized); } - private static void requireSameRequest(HistoryRow replay, String requestHash) { - String snapshot = replay.getAfterSnapshot(); - if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) { + private JsonNode requireSameRequest(HistoryRow replay, String requestHash) { + String rawSnapshot = replay.getAfterSnapshot(); + if (!StringUtils.hasText(rawSnapshot)) { throw conflict("Idempotency-Key 已用于不同请求"); } + try { + JsonNode snapshot = objectMapper.readTree(rawSnapshot); + JsonNode storedHash = snapshot == null ? null : snapshot.get("requestHash"); + if (storedHash == null || !storedHash.isTextual() + || !MessageDigest.isEqual(storedHash.textValue().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw conflict("Idempotency-Key 已用于不同请求"); + } + return snapshot; + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法读取装柜幂等快照", exception); + } + } + + private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) { + JsonNode snapshot = requireSameRequest(replay, requestHash); + try { + JsonNode response = snapshot.get("response"); + if (response == null) throw new IllegalStateException("预装确认幂等响应缺失"); + return objectMapper.treeToValue(response, PreloadConfirmationResponse.class); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法读取预装确认幂等响应", exception); + } } private Map snapshot(AggregateRow row) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index c2ab1987d..6fda57d36 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -30,16 +30,28 @@ import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.service.LoadingDeliveryService; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -58,6 +70,7 @@ import java.time.LocalDateTime; /** 装柜中 PDA 拣货任务及明细同步。 */ @Service public class LoadingPickService { + private static final Logger log = LoggerFactory.getLogger(LoadingPickService.class); private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; private static final String SOURCE_SERVICE = "firstleg-service"; @Resource @@ -65,14 +78,65 @@ public class LoadingPickService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource + private OceanBookingRepository oceanBookingRepository; + @Resource + private LoadingModelBridgeService loadingModelBridge; + @Resource private LoadingListService loadingListService; @Resource + private LoadingDeliveryService loadingDeliveryService; + @Resource + private TruckAppointmentService truckAppointmentService; + @Resource + private InboundStatusHistoryMapper inboundHistories; + @Resource private PdaPickSettlementService settlementService; @Resource private WarehousePalletLocationClient warehouse; @Value("${baiyue.security.internal-service-token:test-internal-service-token}") private String internalToken; + /** Opens the field task only after planning, transport identity and ocean release have converged. */ + @Transactional + public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { + var booking = smartLoadingRepository.booking(containerBookingId); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; + var plan = smartLoadingRepository.planByBooking(containerBookingId); + if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; + var links = loadingModelBridge.byLoadingPlan(plan.getId()); + if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; + var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); + if (allocation == null) return; + var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); + if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) return; + if (repository.taskByPlan(plan.getId()) != null) return; + begin(plan.getId(), booking.getContainerNo(), operatorId, operatorName, + smartLoadingRepository.items(plan.getId())); + } + + public List requirePreloadConfirmationReady(long loadingPlanId) { + List links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId); + for (Link link : links) { + if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { + throw conflict("预装清单已确认或当前状态不允许确认"); + } + var booking = smartLoadingRepository.booking(link.containerBookingId()); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { + throw conflict("确认预装前必须录入真实集装箱号"); + } + var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId()); + if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜"); + var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); + if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) { + throw conflict("确认预装前关联订舱必须已放舱"); + } + if (repository.taskByPlan(link.loadingPlanId()) != null) { + throw conflict("PDA任务已创建,不能重复确认预装"); + } + } + return links; + } + @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, List planItems) { @@ -106,34 +170,46 @@ public class LoadingPickService { @Transactional public void sync(long planId, LoadingPlanItemEntity planItem) { LoadingPickTaskEntity task = repository.taskByPlan(planId); - if (task != null && "LOADING".equals(task.getTaskStatus())) { - syncItem(task.getId(), planItem); + if (task == null) return; + task = repository.taskForUpdate(task.getId()); + requireMutableTask(task); + syncItem(task.getId(), planItem); + if (repository.touchTask(task.getId(), task.getVersion()) != 1) { + throw conflict("PDA任务版本已变化,装柜明细同步失败"); } } - public Task task(String containerNo, long warehouseId) { - if (!StringUtils.hasText(containerNo)) { - throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); - } - if (warehouseId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); - } - LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId); - if (task == null) { - throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); - } - return view(task, warehouseId); + /** Shared lock used by Admin plan mutations and the final PDA completion transaction. */ + @Transactional + public void lockMutable(long planId) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task == null) return; + requireMutableTask(repository.taskForUpdate(task.getId())); } - public Task task(String containerNo, String cabinetNo, Long loadingPlanId, long warehouseId) { - int selectors = (StringUtils.hasText(containerNo) ? 1 : 0) + (StringUtils.hasText(cabinetNo) ? 1 : 0) + /** Read model used by the Admin plan response; mutations still lock the task row separately. */ + public MutationSnapshot mutationSnapshot(long planId) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task == null) return new MutationSnapshot(null, null, null, Map.of()); + Map pickedByPlanItem = repository.items(task.getId()).stream() + .filter(item -> item.getLoadingPlanItemId() != null) + .collect(Collectors.toMap(LoadingPickItemEntity::getLoadingPlanItemId, + item -> count(item.getPickedPackageCount()), Math::max)); + return new MutationSnapshot(task.getId(), task.getTaskStatus(), task.getVersion(), pickedByPlanItem); + } + + public record MutationSnapshot(Long taskId, String taskStatus, Integer taskVersion, + Map pickedByPlanItem) { + } + + public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) { + int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0) + (loadingPlanId != null ? 1 : 0); if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST, - "containerNo、cabinetNo、loadingPlanId必须且只能提供一个"); + "cabinetNo、loadingPlanId必须且只能提供一个"); LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo) ? repository.taskByCabinet(cabinetNo.trim(), warehouseId) - : loadingPlanId != null ? repository.taskByPlan(loadingPlanId, warehouseId) - : repository.taskByContainer(containerNo.trim(), warehouseId); + : repository.taskByPlan(loadingPlanId, warehouseId); if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); return view(task, warehouseId); } @@ -147,6 +223,7 @@ public class LoadingPickService { return repository.pendingTasks(operatorId, normalizedKeyword).stream() .map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(), row.getCabinetNo(), row.getContainerNo(), + row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(), row.getWarehouseId(), row.getWarehouseName(), row.getTaskStatus(), row.getWorkflowStatus(), row.getVersion(), row.getAssignmentVersion(), row.getItemCount(), row.getPendingItemCount())) .toList(); @@ -373,6 +450,17 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); + List owners = repository.inboundOwners(taskId).stream() + .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) + .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) + .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) + .toList(); + if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); + for (LoadingPickItemMapper.InboundOwner owner : owners) { + LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); + if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); + } settlementService.settle(taskId, operatorId, operatorName); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw conflict("柜次仍有未完成PDA拣货的箱数"); @@ -383,9 +471,36 @@ public class LoadingPickService { insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName); loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); repository.syncInboundProjections(taskId, operatorId, operatorName); + for (LoadingPickItemMapper.InboundOwner owner : owners) { + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, + operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); + } + loadingListService.publishCompletedEvent(task.getLoadingPlanId()); + completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); return view(requiredTask(taskId), null); } + private void completeTruckAfterCommit(long containerBookingId, long taskId, + long operatorId, String operatorName) { + Runnable completion = () -> { + try { + truckAppointmentService.completeLoadingFromPda(containerBookingId, taskId, + operatorId, operatorName); + } catch (RuntimeException exception) { + log.warn("PDA loading completed but linked truck appointment was not auto-completed: " + + "containerBookingId={}, taskId={}, reason={}", containerBookingId, taskId, + exception.getMessage()); + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { completion.run(); } + }); + } else { + completion.run(); + } + } + public List operations(long taskId) { requiredTask(taskId); return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(), @@ -399,10 +514,21 @@ public class LoadingPickService { if (item == null) { return; } + LoadingPickTaskEntity task = repository.taskForUpdate(item.getPickTaskId()); + requireMutableTask(task); if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) { throw conflict("已有拣货记录的明细不能删除"); } repository.deleteItem(item.getId()); + if (repository.touchTask(task.getId(), task.getVersion()) != 1) { + throw conflict("PDA任务版本已变化,装柜明细删除失败"); + } + } + + private static void requireMutableTask(LoadingPickTaskEntity task) { + if (task == null || !"LOADING".equals(task.getTaskStatus())) { + throw conflict("PDA任务正在最终确认或已经完成,不能修改预装明细"); + } } private void syncItem(long taskId, LoadingPlanItemEntity planItem) { @@ -447,21 +573,53 @@ public class LoadingPickService { /** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */ private void startPickingIfNeeded(LoadingPickTaskEntity task, long operatorId, String operatorName) { - if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) != 1) { - return; + if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) == 1) { + LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); + history.setLoadingPlanId(task.getLoadingPlanId()); + history.setOperationId("PDA-START-" + task.getId()); + history.setActionType("START_LOADING"); + history.setFromStatus("EDITING"); + history.setToStatus("LOADING"); + history.setBeforeSnapshot("{\"source\":\"PDA\"}"); + history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}"); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + history.setOperationReason("进入PDA拣货任务"); + smartLoadingRepository.insert(history); + } + for (var owner : repository.inboundOwners(task.getId())) { + if (owner.tenantId() == null || owner.inboundId() == null) { + throw conflict("PDA明细缺少入库单归属"); + } + LoadingInboundMapper.Snapshot inbound = repository.inbound(owner.tenantId(), owner.inboundId()); + if (inbound == null || inbound.processStatus() == null) { + throw conflict("PDA关联入库单不存在"); + } + if ("LOADING".equals(inbound.processStatus())) { + continue; + } + if (!"WAIT_SPLIT".equals(inbound.processStatus()) || inbound.version() == null) { + throw conflict("入库单当前状态不能进入装柜阶段"); + } + if (repository.beginInboundLoading(owner.tenantId(), owner.inboundId(), operatorId, + inbound.version()) != 1) { + LoadingInboundMapper.Snapshot refreshed = repository.inbound(owner.tenantId(), owner.inboundId()); + if (refreshed == null || !"LOADING".equals(refreshed.processStatus())) { + throw conflict("入库单状态已变化,请刷新后重试"); + } + continue; + } + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setTenantId(owner.tenantId()); + history.setInboundId(owner.inboundId()); + history.setFromStatus("WAIT_SPLIT"); + history.setToStatus("LOADING"); + history.setAction("PDA_START_LOADING"); + history.setReason("进入PDA拣货任务"); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + inboundHistories.insert(history); } - LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); - history.setLoadingPlanId(task.getLoadingPlanId()); - history.setOperationId("PDA-START-" + task.getId()); - history.setActionType("START_LOADING"); - history.setFromStatus("EDITING"); - history.setToStatus("LOADING"); - history.setBeforeSnapshot("{\"source\":\"PDA\"}"); - history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}"); - history.setOperatorId(operatorId); - history.setOperatorName(operatorName); - history.setOperationReason("进入PDA拣货任务"); - smartLoadingRepository.insert(history); } private LoadingPickTaskEntity requiredTask(long taskId) { @@ -536,9 +694,21 @@ public class LoadingPickService { String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED" : assigned == null ? "UNASSIGNED" : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; - var booking = smartLoadingRepository.booking(task.getContainerBookingId()); - return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(), - booking == null ? null : booking.getContainerNo(), task.getTaskStatus(), + var booking = repository.activeBookingByTask(task.getId()); + if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); + } + var owners = repository.inboundOwners(task.getId()); + Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId(); + var owner = detailInboundId == null ? null : owners.stream() + .filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null); + var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null + ? null : repository.inbound(owner.tenantId(), owner.inboundId()); + return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), + owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(), + inbound == null ? null : inbound.version(), task.getCabinetNo(), + booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(), + booking.getMergedIntoContainerBookingId(), task.getTaskStatus(), workflowStatus, task.getVersion(), assignment, review, items); } @@ -854,6 +1024,7 @@ public class LoadingPickService { } StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') .append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|') + .append(request.containerBookingId()).append('|') .append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim()); request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId, Comparator.nullsFirst(Long::compareTo))) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index f51611c52..eca3c10c2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -152,13 +152,9 @@ public class MeasurementBatchService { item.setHeightCm(source.heightCm()); item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm()) .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); - item.setUnitWeightKg(source.unitWeightKg() == null ? null - : source.unitWeightKg().setScale(3, RoundingMode.CEILING)); item.setUnitVolumeCbm(unitVolume); item.setVolumeRawCbm(totalVolumeRaw); item.setVolumeCbm(totalVolume); - item.setTotalWeightKg(source.unitWeightKg() == null ? null - : source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); item.setCalculationRuleVersion(weighted.calculationRuleVersion()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 1b9afbf20..a15743ea3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -8,6 +8,8 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository; import feign.FeignException; @@ -29,6 +31,8 @@ public class MeasurementLocationOccupationService { @Resource private InboundWorkflowRepository workflow; @Resource + private InboundMeasurementRepository measurements; + @Resource private InboundProperties properties; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -39,6 +43,7 @@ public class MeasurementLocationOccupationService { long warehouseId = requireWarehouse(order); long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID; BigDecimal volume = requireVolume(order); + var palletReferences = confirmedPalletReferences(tenantId, inboundId); // 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键; // 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。 String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion @@ -46,19 +51,16 @@ public class MeasurementLocationOccupationService { WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve( internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId, SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume, - properties.getDefaultPalletCapacityCbm(), "STANDARD", + palletReferences, LocalDateTime.now().plus(properties.getReservationTtl()), true))), "测量完成后库位预占失败"); int packageCount = requirePackageCount(order); - allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), packageCount, reserved); + allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, reserved); try { WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId, - SOURCE_SERVICE, inboundId, operatorId, volume, - properties.getDefaultPalletCapacityCbm(), packageCount))), "测量完成后库位占用失败"); - allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), packageCount, occupied); + SOURCE_SERVICE, inboundId, operatorId, volume, packageCount))), "测量完成后库位占用失败"); + allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, occupied); } catch (RuntimeException failure) { try { warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( @@ -97,6 +99,36 @@ public class MeasurementLocationOccupationService { return order.getTotalPackageCount(); } + private java.util.List confirmedPalletReferences( + long tenantId, long inboundId) { + java.util.List references = new java.util.ArrayList<>(); + for (InboundWeightResultEntity weight : measurements.listWeights(tenantId, inboundId)) { + if (!"CONFIRMED".equals(weight.getRecognitionStatus())) { + continue; + } + if (weight.getConfirmedAt() == null) { + throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少确认时间,无法分配库位"); + } + if (weight.getNetWeightKg() == null || weight.getNetWeightKg().signum() <= 0) { + throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少有效净重,无法分配库位"); + } + if (weight.getPalletId() == null || weight.getPalletId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少有效托盘,无法分配库位"); + } + if (weight.getId() == null || weight.getId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少结果标识,无法分配库位"); + } + references.add(new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId())); + } + if (references.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量"); + } + if (references.size() > 200) { + throw new BizException(ResultCode.VALIDATION_FAILED, "单票最多记录200张已确认称重图片"); + } + return references; + } + private static ApiResponse call(RemoteCall call) { try { return call.invoke(); } catch (FeignException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index a5d30516f..6700292b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -21,13 +21,15 @@ import java.time.LocalDateTime; import java.math.BigDecimal; /** - * 测量审核应用服务。审核通过后进入内部计费编排,完成费用发布和入库后再进入 WAIT_SPLIT。 + * 测量审核应用服务。审核通过后直接进入 WAIT_SPLIT,费用初算和人工维护不阻塞主流程。 */ @Service public class MeasurementReviewService { @Resource private InboundWorkflowRepository repository; @Resource + private com.baiyue.firstleg.repository.InboundMeasurementRepository measurements; + @Resource private InboundIdempotencyService idempotency; @Resource private InboundProperties properties; @@ -78,13 +80,30 @@ public class MeasurementReviewService { throw new BizException(ResultCode.VALIDATION_FAILED, "人工确认箱数合计与入库单总箱数不一致,不能审核通过"); } - BigDecimal weight = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalWeightKg) - .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + var weights = measurements.listWeights(tenantId, inboundId); + if (weights.isEmpty() || weights.stream().anyMatch(weight -> + !"CONFIRMED".equals(weight.getRecognitionStatus()) + || weight.getGrossWeightKg() == null + || weight.getGrossWeightKg().signum() <= 0 + || weight.getNetWeightKg() == null + || weight.getNetWeightKg().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过"); + } + BigDecimal grossWeight = weights.stream() + .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal netWeight = weights.stream() + .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm) .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); - if (weight.signum() <= 0 || volume.signum() <= 0) { + if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null + || order.getActualGrossWeightKg().compareTo(grossWeight) != 0 + || order.getActualNetWeightKg().compareTo(netWeight) != 0 + || volume.signum() <= 0) { throw new BizException(ResultCode.VALIDATION_FAILED, - "审核通过的测量快照必须包含大于零的实重和体积"); + "审核通过前称重毛重、货物净重汇总和体积必须完整且一致"); } } @@ -99,7 +118,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "WAIT_PRICING" : "MEASURE_REJECTED", + approved ? "WAIT_SPLIT" : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } @@ -135,7 +154,7 @@ public class MeasurementReviewService { } repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", null, true, request.reason(), true, operatorId, operatorName)); - repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_PRICING", + repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_SPLIT", "MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 931065259..d3693f300 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -53,6 +53,7 @@ public class MerchantInboundBillingService { var summary = new MerchantInboundBillingResponse.MeasurementSummary( measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(), measurement.actualPackageCount(), measurement.actualGrossWeightKg(), + measurement.actualNetWeightKg(), measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName()); var measurementImages = measurement.items().stream() .filter(item -> item.intelligentImageFileId() != null) @@ -68,14 +69,16 @@ public class MerchantInboundBillingService { var measurementItems = measurement.items().stream() .map(item -> new MerchantInboundBillingResponse.MeasurementItem( item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(), - item.lengthCm(), item.widthCm(), item.heightCm(), item.unitWeightKg(), - item.totalWeightKg(), item.unitVolumeCbm(), item.totalVolumeCbm(), item.volumeWeightKg())) + item.lengthCm(), item.widthCm(), item.heightCm(), item.unitVolumeCbm(), + item.totalVolumeCbm(), item.volumeWeightKg())) .toList(); var weightResults = measurement.weightResults().stream() .map(weight -> new MerchantInboundBillingResponse.WeightResult( - weight.id(), weight.fileId(), weight.ocrWeightValue(), weight.ocrWeightUnit(), - weight.adjustedWeightValue(), weight.adjustedWeightUnit(), - weight.finalWeightKg(), weight.recognitionStatus())) + weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(), + weight.palletCapacityCbm(), weight.palletTareWeightKg(), + weight.ocrWeightValue(), weight.ocrWeightUnit(), + weight.grossWeightKg(), weight.adjustedWeightValue(), weight.adjustedWeightUnit(), + weight.netWeightKg(), weight.recognitionStatus())) .toList(); var basis = new MerchantInboundBillingResponse.BillingBasis( fee.chargeMode(), fee.chargeUnit(), chargeableBasis(fee), fee.actualWeightKg(), @@ -146,7 +149,7 @@ public class MerchantInboundBillingService { detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(), detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(), - detail.getInboundedAt(), detail.getSettlementIncluded()); + detail.getInboundedAt()); } public record ImageDownload(byte[] content, String contentType) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 09c2a5712..87d608bca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; +import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; @@ -47,14 +48,25 @@ public class MerchantPriceBookService { estimate.amount(), estimate.currency(), DISCLAIMER); } - public void requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode) { + public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode, + String destinationType, boolean requireInboundCodePrefix) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); Long versionId = repository.activeVersionId(); - if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, - normalizeChannel(channelCode))) { + String normalized = normalizeChannel(channelCode); + if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); } + if (!requireInboundCodePrefix) { + return new Response(normalized, null); + } + var prefix = repository.channelPrefix(normalized); + if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) { + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置进仓编码"); + } + String selected = "OVERSEAS".equals(destinationType) && StringUtils.hasText(prefix.overseasInboundCodePrefix()) + ? prefix.overseasInboundCodePrefix() : prefix.inboundCodePrefix(); + return new Response(normalized, selected); } private CurrentChannel toChannel(ChannelRow row, Map> taxModes) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index aa92f3f55..1bc4ee8a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -77,7 +77,7 @@ public class MojiaMeasurementOpenApiService { try { result = cartonMeasurements.upsert( app.tenantId(), app.subjectId(), new CartonMeasurementCommand( - request.boxCode(), request.length(), request.width(), request.height(), request.weight(), + request.boxCode(), request.length(), request.width(), request.height(), request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(), image, optionalTrim(request.shipmentId()))); } catch (BizException exception) { @@ -87,10 +87,10 @@ public class MojiaMeasurementOpenApiService { throw exception; } log.info("MOJIA_MEASUREMENT accepted requestId={} traceId={} boxCode={} machineNo={} " - + "lengthCm={} widthCm={} heightCm={} weightKg={} imageBytes={} measurementId={} " + + "lengthCm={} widthCm={} heightCm={} imageBytes={} measurementId={} " + "itemId={} created={}", correlation("requestId"), correlation("traceId"), safe(request.boxCode()), - safe(request.machineNo()), request.length(), request.width(), request.height(), request.weight(), + safe(request.machineNo()), request.length(), request.width(), request.height(), image.getSize(), result.measurementId(), result.itemId(), result.created()); return new UploadResponse(true, true, null, result.measurementId(), result.itemId(), result.boxCode(), result.imageFileId(), result.created()); @@ -121,7 +121,6 @@ public class MojiaMeasurementOpenApiService { receipt.setLengthCm(request.length()); receipt.setWidthCm(request.width()); receipt.setHeightCm(request.height()); - receipt.setWeightKg(request.weight()); receipt.setMachineNo(request.machineNo().trim()); receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt()); receipt.setImageFileId(uploaded.fileId()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 335660bea..d8d77120c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -8,7 +8,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.split.service.SplitPreloadService; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -23,6 +26,7 @@ import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; import java.time.LocalDateTime; import java.util.*; @@ -33,6 +37,9 @@ public class SmartLoadingService { @Resource private SmartLoadingRepository repository; @Resource private OrderCargoClient orderCargoClient; @Resource private LoadingPickService loadingPickService; + @Resource private LoadingModelBridgeService loadingModelBridge; + @Resource private SplitPreloadService splitPreloadService; + @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } @@ -47,7 +54,8 @@ public class SmartLoadingService { ContainerBookingEntity booking = requireBooking(bookingId); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填"); - return candidates(warehouseId).stream().filter(candidate -> sameChannel(booking, candidate)).toList(); + return candidates(warehouseId).stream() + .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); } public Plan draftByBooking(long bookingId) { @@ -70,7 +78,7 @@ public class SmartLoadingService { LoadingPlanEntity existing = repository.planByBooking(bookingId); if (existing != null) return draftView(existing, booking); LoadingPlanEntity plan = new LoadingPlanEntity(); - plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20)); + plan.setPlanNo(documentNumbers.next("LP")); plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark)); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0); @@ -100,6 +108,9 @@ public class SmartLoadingService { Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); + requireSameCustomerScope(booking, candidate); + splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); @@ -116,16 +127,19 @@ public class SmartLoadingService { try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } changed = item; } else { - existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total))); + existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(total))); + existing.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(total))); existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total); existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName); if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改"); changed = existing; } - history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation, + history(planId, changed.getId(), "ADD_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedVersion())), json(values("requestHash", requestHash, "itemId", changed.getId(), "packageCount", changed.getPackageCount())), operatorId, operatorName); + loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), + operatorId, operatorName, operation); loadingPickService.sync(planId, changed); return view(repository.plan(planId), booking); } @@ -143,6 +157,7 @@ public class SmartLoadingService { for (Candidate candidate : candidates(booking.getWarehouseId())) { if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; if (!sameChannel(booking, candidate)) continue; + if (!sameCustomerScope(booking, candidate)) continue; if (StringUtils.hasText(booking.getDestinationWarehouseCode()) && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; int count = fittingBoxes(booking, repository.items(planId), candidate); @@ -154,7 +169,7 @@ public class SmartLoadingService { } } LoadingPlanEntity recommended = repository.plan(planId); - history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation, + history(planId, null, "SMART_RECOMMEND", plan.getPlanStatus(), recommended.getPlanStatus(), operation, json(values("version", request.expectedVersion())), json(values("requestHash", requestHash, "version", recommended.getVersion())), operatorId, operatorName); @@ -179,17 +194,19 @@ public class SmartLoadingService { Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); requireSameChannel(booking, candidate); + requireSameCustomerScope(booking, candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); checkRouteAndCapacity(booking, others, candidate, request.packageCount()); touch(plan, operatorId, operatorName); item.setPackageCount(request.packageCount()); - item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount()); item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); - history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation, + history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("requestHash", requestHash, "packageCount", request.packageCount())), operatorId, operatorName); @@ -197,6 +214,40 @@ public class SmartLoadingService { return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); } + @Transactional + @BusinessAudit(event = "SMART_LOADING_WAREHOUSE_REMARK", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "修改装柜仓库备注") + public Plan updateWarehouseRemark(long planId, long itemId, WarehouseRemarkRequest request, + long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String remark = trim(request.warehouseRemark()); + if (!StringUtils.hasText(remark)) remark = null; + String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|' + + Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|' + + request.expectedItemVersion()); + Plan replay = replay(operation, planId, "WAREHOUSE_REMARK", itemId, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); + LoadingPlanItemEntity item = repository.item(planId, itemId); + if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + String before = json(values("warehouseRemark", item.getWarehouseRemark(), "version", item.getVersion())); + try { + touch(plan, operatorId, operatorName); + if (repository.updateWarehouseRemark(planId, itemId, request.expectedPlanVersion() + 1, + request.expectedItemVersion(), remark, operatorId, operatorName) != 1) { + throw conflict("装柜计划或明细已被其他人修改,请刷新后重试"); + } + } catch (DataAccessException exception) { + if (hasSqlState(exception, "45000")) { + throw conflict("装柜计划或明细状态已变化,仓库备注未保存,请刷新后重试"); + } + throw exception; + } + history(planId, itemId, "WAREHOUSE_REMARK", plan.getPlanStatus(), plan.getPlanStatus(), operation, + before, json(values("warehouseRemark", remark, "requestHash", requestHash)), operatorId, operatorName); + return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); + } + @Transactional @BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM", resourceId = "#itemId", description = "删除智能装柜明细") @@ -212,7 +263,8 @@ public class SmartLoadingService { loadingPickService.remove(itemId); touch(plan, operatorId, operatorName); if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改"); - history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation, + loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId()); + history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); @@ -224,38 +276,72 @@ public class SmartLoadingService { public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) { String key = trim(idempotencyKey); if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); + String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion()); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); - if (key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId())); - if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); - if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - List items = repository.items(planId); - if (items.isEmpty()) throw validation("装柜明细不能为空"); - checkPlanCapacity(booking, items); - if (StringUtils.hasText(booking.getContainerNo())) { - loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, items); + if (key.equals(plan.getPushIdempotencyKey())) { + requirePushReplay(planId, key, requestHash); + return view(plan, booking); } + if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); + if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求"); + if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); + List items = repository.items(planId); + ensurePendingPreloads(items, operatorId, operatorName); + checkPlanCapacity(booking, items); try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } - history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName); + try { + history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash)), operatorId, operatorName); + } catch (DuplicateKeyException exception) { + throw conflict("Idempotency-Key已用于不同推送请求"); + } LoadingPlanEntity pushed = repository.plan(planId); + loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key); return view(pushed, requireBooking(pushed.getContainerBookingId())); } + private void requirePushReplay(long planId, String key, String requestHash) { + LoadingPlanHistoryEntity history = repository.operation(key); + if (history == null + || !Objects.equals(history.getLoadingPlanId(), planId) + || !Objects.equals(history.getActionType(), "PUSH_PRELOAD") + || history.getAfterSnapshot() == null + || !sameRequestHash(history.getAfterSnapshot(), requestHash)) { + throw conflict("Idempotency-Key已用于不同推送请求"); + } + } + + private void ensurePendingPreloads(List items, long operatorId, String operatorName) { + if (items.isEmpty()) throw validation("装柜明细不能为空"); + if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) { + throw conflict("装柜明细缺少客户或入库单稳定归属,不能推送预装计划"); + } + items.stream().collect(java.util.stream.Collectors.toMap( + item -> item.getAccountId() + "|" + item.getInboundId(), item -> item, (left, right) -> left)) + .values().forEach(item -> splitPreloadService.ensurePendingForLoadingAllocation( + item.getAccountId(), item.getInboundId(), operatorId, operatorName)); + } + private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) { LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName()); i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode()); i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count); - i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + i.setGrossWeightKg(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count))); + i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count))); + i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i; } private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) { item.setSkuCode(cargo.skuNo()); item.setProductNameEn(cargo.cargoNameEn()); item.setUnitsPerCarton(cargo.unitsPerPackage()); - item.setInternalRemark(cargo.cargoRemark()); + item.setCustomerRemark(cargo.customerRemark()); + item.setInternalRemark(cargo.latestInternalNote()); int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0 ? boxes : cargo.originalPackageCount(); if (cargo.unitsPerPackage() != null) { @@ -265,10 +351,6 @@ public class SmartLoadingService { .multiply(BigDecimal.valueOf(boxes)) .divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact()); } - if (cargo.netWeightKg() != null) { - item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes)) - .divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP)); - } } private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) { @@ -300,7 +382,7 @@ public class SmartLoadingService { if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致"); if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致"); BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); - BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); + BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count))); if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置"); if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0 || w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) { @@ -329,10 +411,31 @@ public class SmartLoadingService { BigDecimal leftV=b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO).subtract(usedV); BigDecimal leftW=b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO).subtract(usedW); int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue(); - int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue(); + int byW=unit(c.unitGrossWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitGrossWeightKg()).intValue(); return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW))); } - private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));} + private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){ + var task = loadingPickService.mutationSnapshot(p.getId()); + boolean planMutable = Set.of("EDITING", "LOADING").contains(p.getPlanStatus()); + boolean taskMutable = task.taskId() == null || "LOADING".equals(task.taskStatus()); + boolean mutable = planMutable && taskMutable; + String blockedReason = mutable ? null : "COMPLETED".equals(p.getPlanStatus()) + || "COMPLETED".equals(task.taskStatus()) ? "装柜已最终完成,不能修改预装明细" + : "当前计划或PDA任务状态不允许修改预装明细"; + List items = repository.items(p.getId()); + for (LoadingPlanItemEntity item : items) { + int picked = task.pickedByPlanItem().getOrDefault(item.getId(), 0); + item.setPickedPackageCount(picked); + item.setMinPackageCount(Math.max(1, picked)); + item.setCanAdjust(mutable); + item.setCanRemove(mutable && picked == 0); + } + String finalStatus = task.taskId() == null ? "NOT_STARTED" + : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; + return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), + b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, + task.taskId(),task.taskStatus(),task.taskVersion(),items); + } private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); if (!Set.of("EDITING", "LOADING").contains(plan.getPlanStatus())) { @@ -346,11 +449,7 @@ public class SmartLoadingService { if (!Set.of("EDITING", "LOADING").contains(p.getPlanStatus()) || !Objects.equals(p.getVersion(), version)) { throw conflict("计划已完成或版本冲突"); } - if ("LOADING".equals(p.getPlanStatus()) - && repository.recycleUnpicked(id, version, operatorId, operatorName) == 1) { - p.setPlanStatus("EDITING"); - p.setVersion(version + 1); - } + loadingPickService.lockMutable(id); return p; } private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;} @@ -358,12 +457,18 @@ public class SmartLoadingService { return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode()) && booking.getChannelCode().equals(candidate.channelCode()); } + private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { + return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); + } private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) { if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) { throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填"); } if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装"); } + private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { + if (!sameCustomerScope(booking, candidate)) throw forbidden(); + } private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { LoadingPlanHistoryEntity history = repository.operation(operation); @@ -441,6 +546,15 @@ public class SmartLoadingService { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用"); } + private static boolean hasSqlState(Throwable throwable, String sqlState) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof SQLException sqlException && sqlState.equals(sqlException.getSQLState())) { + return true; + } + } + return false; + } + private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);} private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index d082f19d5..16ea6f752 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -11,12 +11,14 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.repository.TruckAppointmentRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.client.TruckAppointmentFileClient; import jakarta.annotation.Resource; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,35 +27,49 @@ import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.Objects; +import java.util.Set; import org.springframework.web.multipart.MultipartFile; @Service public class TruckAppointmentService { + private static final Set ATTACHMENT_TYPES = Set.of("BILL_OF_LADING", "DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER"); @Resource TruckAppointmentRepository repo; @Resource OceanBookingRepository oceanBookings; @Resource SmartLoadingRepository loadingPlans; + @Resource ContainerRepository containers; @Resource TruckAppointmentFileClient files; + @Resource WarehouseSupplierSnapshotService suppliers; + @Resource IamCustomerSnapshotService customers; + @Resource FirstlegDocumentNumberGenerator documentNumbers; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; - public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);} + public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} - @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(type);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} - @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} + @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} + @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} - @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); validateBookingLink(r); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} - @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); validateBookingLink(r); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} - @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return enrich(e);} + @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} + @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} - private void validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);} - private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");} + @Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);} + private AssociationContext validateAssociations(SaveRequest r,long customerId){OceanBookingEntity booking=null;ContainerBookingEntity container=null;Long warehouseId=r.warehouseId();String billOfLadingNo=trim(r.billOfLadingNo());if(r.oceanBookingId()!=null){booking=oceanBookings.find(r.oceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");warehouseId=mergeWarehouse(warehouseId,booking.getWarehouseId(),"订舱");if("RELEASED".equals(booking.getStatus()))billOfLadingNo=releasedBookingBill(booking,billOfLadingNo);}if(r.containerBookingId()!=null){container=containers.find(r.containerBookingId());if(container==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜次不存在");if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");}if(booking!=null&&container!=null){long containerId=container.getId();boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));if(!allocated)throw bad("containerBookingId未分配至该订舱");}if(r.loadingPlanId()!=null){if(container==null)throw bad("选择loadingPlanId时必须同时选择containerBookingId");LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}return new AssociationContext(booking,container,warehouseId,billOfLadingNo);} + private static void validateRequired(SaveRequest r){if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} + private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;} private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} - private void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String customerName,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(customerName);e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");} private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");} private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){} + private String requireDispatchReady(TruckAppointmentEntity appointment){String bill=trim(appointment.getBillOfLadingNo());if(appointment.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if("RELEASED".equals(booking.getStatus()))bill=releasedBookingBill(booking,bill);}if(!StringUtils.hasText(bill))throw conflict("派车前必须填写提单号");boolean active=false;java.util.List references=repo.attachments(appointment.getId());if(references!=null)for(TruckAppointmentAttachmentEntity attachment:references){if(!"BILL_OF_LADING".equals(attachment.getAttachmentType()))continue;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(appointment.getId()),attachment.getFileId());if(response!=null&&response.successful()&&response.data()!=null){active=true;break;}}catch(FeignException.NotFound ignored){}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}if(!active)throw conflict("派车前必须上传有效提单附件");return bill;} + private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} + private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} + private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;} + private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index d3a06e2ca..219b72682 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -7,7 +7,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundDetail; import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.UnifiedInboundSummary; import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.dto.CurrentContainerTransport; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.repository.InboundOrderRepository; import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; @@ -75,9 +75,10 @@ public class UnifiedInboundQueryService { evidence = operations.findEvidence(tenantId, inbound.getId()); deliveryAt = deliveryAt(detail.statusHistory()); } - InboundTransportSnapshotEntity transport = includePhaseThree + CurrentContainerTransport transport = includePhaseThree ? operations.findTransport(tenantId, inbound.getId()) : null; - return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt, transport, null), timeline); + return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt, + transport, null, null), timeline); } private List summaries(List records) { @@ -93,17 +94,19 @@ public class UnifiedInboundQueryService { Map owners = records.stream().collect(Collectors.toMap( InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left)); var locationCodes = inbounds.locationCodesMap(null, owners); + var pickups = inbounds.pickupMap(null, owners); return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()), picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()), deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(), - transports.get(inbound.getId()), locationCodes.get(inbound.getId()))).toList(); + transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList(); } private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, List evidence, LocalDateTime deliveryAt, - InboundTransportSnapshotEntity transport, String locationCodes) { + CurrentContainerTransport transport, String locationCodes, + com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) { String mainStatus = mainStatus(inbound.getProcessStatus()); List activeEvidence = evidence.stream() .filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList(); @@ -121,13 +124,22 @@ public class UnifiedInboundQueryService { ? new UnifiedInboundSummary.DeliverySummary( "DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt, inbound.getExpressTrackingNo()) : null; - var inboundSummary = detail == null - ? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, locationCodes) : detail.summary(); + var baseSummary = detail == null ? null : detail.summary(); + var inboundSummary = com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, + pickup == null && baseSummary != null && baseSummary.requestedPickupAt() != null + ? pickupSnapshot(baseSummary.requestedPickupAt()) : pickup, + locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes); return new UnifiedInboundSummary(inboundSummary, mainStatus, nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()), preloadSummary, loadingSummary, deliverySummary); } + private com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickupSnapshot(LocalDateTime pickupAt) { + var snapshot = new com.baiyue.firstleg.entity.InboundPickupSnapshotEntity(); + snapshot.setPickupAt(pickupAt); + return snapshot; + } + private LocalDateTime deliveryAt(List history) { return history.stream() .filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java new file mode 100644 index 000000000..634e908d4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class WarehouseSupplierSnapshotService { + @Resource private WarehouseSupplierClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public WarehouseSupplierClient.Snapshot booking(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "BOOKING"); + } + + public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "TRUCKING"); + } + + public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "CUSTOMS"); + } + + /** 费用来源只提交供应商 ID;类别为空时仅校验 ACTIVE,名称始终取 Warehouse 快照。 */ + public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) { + return resolve(supplierId, null, FeeItemSupplierCategory.normalize(category)); + } + + private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName, + String category) { + if (supplierId == null || supplierId <= 0) { + if (StringUtils.hasText(suppliedName)) throw new BizException(ResultCode.BAD_REQUEST, + "供应商名称必须来自有效的供应商ID,禁止自由文本"); + return null; + } + try { + var response = client.get(internalToken, supplierId, true, category); + if (response == null || !response.successful() || response.data() == null + || response.data().supplierId() != supplierId + || !"ACTIVE".equals(response.data().status()) + || (StringUtils.hasText(category) && !category.equals(response.data().category())) + || !StringUtils.hasText(response.data().name())) { + throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别不匹配"); + } + if (StringUtils.hasText(suppliedName) + && !suppliedName.trim().equals(response.data().name())) { + throw new BizException(ResultCode.BAD_REQUEST, "供应商名称与服务端主数据不一致"); + } + return response.data(); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "供应商主数据服务暂不可用"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java index 1864e0339..f8e802674 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java @@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.service.FirstlegScopeGuard; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; @@ -44,32 +43,6 @@ public class PlatformSplitPreloadController { return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request)); } - @Operation(summary = "提交预装清单确认") - @PostMapping("/{inboundId}/preload/confirming") - @PreAuthorize("hasAuthority('firstleg:inbound:preload')") - @BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId", - description = "提交预装清单确认") - public ApiResponse confirming( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, - @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ConfirmPreloadRequest request) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request)); - } - - @Operation(summary = "确认预装清单并进入装柜中") - @PostMapping("/{inboundId}/preload/confirm") - @PreAuthorize("hasAuthority('firstleg:inbound:preload')") - @BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId", - description = "确认预装清单") - public ApiResponse confirm( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, - @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ConfirmPreloadRequest request) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request)); - } - @Operation(summary = "查询预装清单") @GetMapping("/{inboundId}/preload") @PreAuthorize("hasAuthority('firstleg:inbound:preload-view')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java index 2caf3352b..0343cf03e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java @@ -18,8 +18,6 @@ public final class SplitRequests { List<@Valid PreloadItem> items, @Size(max = 1000) String remark) {} - public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {} - public record PreloadItem( @Size(max = 64) String containerCode, @Size(max = 128) String cargoReference, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java index efe37bd23..c43d5921a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java @@ -16,12 +16,4 @@ public interface PreloadPlanMapper extends BaseMapper { @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion); - @Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, " - + "updated_by_name=#{operatorName}, version=version+1 " - + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} " - + "AND version=#{expectedVersion}") - int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, - @Param("expectedVersion") int expectedVersion); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java index c608e8c06..2b797ea91 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.split.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; @Mapper public interface SplitInboundMapper { @@ -11,11 +10,5 @@ public interface SplitInboundMapper { + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " - + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " - + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") - int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); - record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java index fc0d5a2bf..0e6097f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java @@ -20,10 +20,6 @@ public class SplitRepository { return inbounds.find(tenantId, inboundId); } - public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { - return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion); - } - public PreloadPlanEntity findPlan(long tenantId, long inboundId) { return plans.selectOne(new LambdaQueryWrapper() .eq(PreloadPlanEntity::getTenantId, tenantId) @@ -39,12 +35,6 @@ public class SplitRepository { return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion); } - public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus, - long operatorId, String operatorName, int expectedVersion) { - return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus, - operatorId, operatorName, expectedVersion); - } - public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId, String operation, String key) { return operations.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 62e12ce52..70146cf38 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -2,11 +2,7 @@ package com.baiyue.firstleg.split.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; -import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; -import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.split.domain.PreloadStatus; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; @@ -22,20 +18,16 @@ import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.dao.DuplicateKeyException; -import java.time.LocalDateTime; import java.util.List; /** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */ @Service public class SplitPreloadService { private static final String SMART_SPLIT = "SMART_SPLIT"; - private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING"; - private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD"; @Resource private SplitRepository repository; - @Resource private LoadingModelBridgeService loadingModelBridge; - @Resource private InboundStatusHistoryMapper histories; @Resource private ObjectMapper objectMapper; @Transactional @@ -72,65 +64,32 @@ public class SplitPreloadService { new SplitActionResponse("WAIT_SPLIT", result)); } + /** Ensures the inbound-level preload lifecycle exists before a loading allocation can be pushed. */ @Transactional - public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId, - String operatorName, String key, - ConfirmPreloadRequest request) { - SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key); - if (replay != null) return replay; + public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - boolean loading = "LOADING".equals(inbound.processStatus()); - if (loading) { - // Starting a real loading task advances the inbound before its preload - // confirmation is submitted; retain the linked-container hard gate. - loadingModelBridge.requireStarted(tenantId, inboundId); - } else { - requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认"); + requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); + PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + if (plan != null) { + if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); + } + return; } - PreloadPlanEntity plan = requirePlan(tenantId, inboundId); - if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { - return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(plan))); + plan = new PreloadPlanEntity(); + plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING); + plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("智能装柜分配自动建立"); + plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); + plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); + try { + repository.createPlan(plan); + } catch (DuplicateKeyException exception) { + PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId); + if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试"); + } } - if (!PreloadStatus.PENDING.equals(plan.getStatus()) - || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, - PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) { - throw concurrent(); - } - return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", - response(repository.findPlan(tenantId, inboundId)))); - } - - @Transactional - public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId, - String operatorName, String key, - ConfirmPreloadRequest request) { - SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key); - if (replay != null) return replay; - InboundSnapshot inbound = requireInbound(tenantId, inboundId); - if ("LOADING".equals(inbound.processStatus())) { - loadingModelBridge.requireStarted(tenantId, inboundId); - return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, - new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); - } - requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单"); - PreloadPlanEntity plan = requirePlan(tenantId, inboundId); - if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); - } - loadingModelBridge.requireStarted(tenantId, inboundId); - if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED, - operatorId, operatorName, request.expectedVersion()) != 1) { - throw concurrent(); - } - if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { - throw concurrent(); - } - addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD, - operatorId, operatorName); - return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, - new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); } public PreloadPlanResponse detail(long tenantId, long inboundId) { @@ -180,19 +139,6 @@ public class SplitPreloadService { return "WAIT_SPLIT".equals(status); } - private void addHistory(long tenantId, long inboundId, String from, String to, String action, - long operatorId, String operatorName) { - InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); - history.setTenantId(tenantId); - history.setInboundId(inboundId); - history.setFromStatus(from); - history.setToStatus(to); - history.setAction(action); - history.setOperatorId(operatorId); - history.setOperatorName(operatorName); - histories.insert(history); - } - private PreloadPlanResponse response(PreloadPlanEntity plan) { if (plan == null) return null; return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(), diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index 67109767a..f5aea388d 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -71,12 +71,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照', fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', - partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', - partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', + supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', - settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池', review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本', @@ -91,22 +91,52 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status), - KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status), + KEY idx_container_fee_review (tenant_id, review_status, source_status), CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'), CONSTRAINT chk_container_fee_amount CHECK (amount >= 0), CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')), CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED')) -) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; +) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance'; -CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id', - "BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); -CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name', - "VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); -CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id', - "BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); -CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name', - "VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); +CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( + id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用快照ID', + source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', + source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID', + container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', + container_no VARCHAR(64) NULL COMMENT '柜号快照', + fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID快照', + fee_item_code VARCHAR(50) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', + direction VARCHAR(16) NOT NULL COMMENT '费用方向', + supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照', + supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + business_date DATE NOT NULL COMMENT '费用业务日期', + amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', + currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + review_status VARCHAR(16) NOT NULL COMMENT '审核状态快照', + reviewed_at DATETIME(3) NULL COMMENT '审核时间快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version), + KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id) +) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照'; + +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', + "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', + "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', + "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', + "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', + "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', + "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; @@ -125,13 +155,13 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' - AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整'; + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' - AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整'; + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql index 10f26a5c0..96abe7534 100644 --- a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql +++ b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( category VARCHAR(64) NOT NULL COMMENT '费用分类', trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码', + supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', @@ -36,8 +37,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', - partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', - partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', + supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空', business_date DATE NOT NULL COMMENT '业务发生日', fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照', trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照', diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql index 11869f9a4..69cfd6e26 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -45,14 +45,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER volume_cbm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER volume_cbm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', @@ -77,7 +73,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index b80cf4024..99f24190d 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', + inbound_code VARCHAR(40) NOT NULL COMMENT '全平台唯一入仓码', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', @@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', + actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg', chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', - actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', + actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片扣除托盘皮重后的货物净重汇总kg', measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code), + UNIQUE KEY uk_inbound_code (inbound_code), UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), @@ -77,6 +77,33 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +-- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 +SET @inbound_global_code_index_sql = IF( + NOT EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_code' + ), + 'ALTER TABLE baiyue_firstleg.fl_inbound_order ADD UNIQUE KEY uk_inbound_code (inbound_code)', + 'SELECT 1' +); +PREPARE inbound_global_code_index_stmt FROM @inbound_global_code_index_sql; +EXECUTE inbound_global_code_index_stmt; +DEALLOCATE PREPARE inbound_global_code_index_stmt; + +SET @inbound_tenant_code_index_drop_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_code' + ), + 'ALTER TABLE baiyue_firstleg.fl_inbound_order DROP INDEX uk_inbound_tenant_code', + 'SELECT 1' +); +PREPARE inbound_tenant_code_index_drop_stmt FROM @inbound_tenant_code_index_drop_sql; +EXECUTE inbound_tenant_code_index_drop_stmt; +DEALLOCATE PREPARE inbound_tenant_code_index_drop_stmt; + CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -215,11 +242,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm', girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', - unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3', volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', - total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本', @@ -249,7 +274,6 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm', - weight_kg DECIMAL(18,3) NULL COMMENT '设备实测重量kg', machine_no VARCHAR(64) NOT NULL COMMENT '设备编号', measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID', @@ -270,6 +294,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -277,7 +306,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -293,8 +323,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -307,7 +338,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -421,6 +453,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', @@ -511,26 +545,29 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel ( ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'; INSERT INTO fl_price_channel_catalog - (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status) + (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, + inbound_code_prefix, overseas_inbound_code_prefix, status) VALUES - (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), - (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), - (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), - (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), - (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'), - (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'), - (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), - (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), - (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), - (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), - (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), - (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), - (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), - (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), - (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') + (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'), + (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'), + (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'), + (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'), + (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'NYEXX', NULL, 'ACTIVE'), + (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'), + (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'), + (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'), + (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'), + (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'), + (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'), + (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'), + (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'), + (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'), + (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE') ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), price_model = VALUES(price_model), - source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status); + source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), + inbound_code_prefix = VALUES(inbound_code_prefix), + overseas_inbound_code_prefix = VALUES(overseas_inbound_code_prefix), status = VALUES(status); INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status) VALUES @@ -566,7 +603,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', @@ -659,7 +696,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), KEY idx_allocation_reservation (reservation_id), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 9390524ef..4fa48c82d 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -22,6 +22,8 @@ DELIMITER ; CALL phase2_publish_add_column('fl_fee_item', 'fee_code', "VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope"); +CALL phase2_publish_add_column('fl_fee_item', 'supplier_category', + "VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商' AFTER fee_code"); ALTER TABLE fl_fee_item MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', COMMENT = '平台公共费用项目主数据'; @@ -406,7 +408,7 @@ DEALLOCATE PREPARE statement; CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_type', "VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING' AFTER fee_item_id"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_id', - "BIGINT UNSIGNED NULL COMMENT '来源计费快照ID' AFTER source_type"); + "BIGINT UNSIGNED NULL COMMENT '来源计费快照ID或人工费用版本链根ID' AFTER source_type"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_version', "INT UNSIGNED NULL COMMENT '来源计费快照版本' AFTER source_fee_id"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_no', @@ -431,9 +433,6 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_at', "DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'inbounded_at', "DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at"); -CALL phase2_publish_add_column('fl_inbound_fee_detail', 'settlement_included', - "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at"); - DROP PROCEDURE IF EXISTS phase2_publish_add_column; DROP PROCEDURE IF EXISTS phase2_publish_add_index; @@ -457,12 +456,21 @@ DELIMITER ; CALL phase2_publish_add_index('fl_fee_item', 'uk_fee_item_code', 'UNIQUE KEY `uk_fee_item_code` (`fee_code`)'); CALL phase2_publish_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source', - 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)'); + 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)'); CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published', 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS phase2_publish_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema; DELIMITER $$ CREATE PROCEDURE phase2_publish_assert_fee_schema() @@ -483,6 +491,10 @@ BEGIN OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index b89ba7da3..7e72d231d 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -90,31 +90,28 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-customs-schema.sql b/firstleg/src/main/resources/db/phase3-customs-schema.sql index 84ffef072..d02b3df08 100644 --- a/firstleg/src/main/resources/db/phase3-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-customs-schema.sql @@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号', batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', + customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空', customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号', file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', @@ -115,6 +116,22 @@ CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED')) ) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本'; +-- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。 +DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch' + AND column_name='customs_agent_id') THEN + ALTER TABLE fl_customs_submission_batch + ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version; + END IF; +END$$ +DELIMITER ; +CALL phase3_customs_supplier_snapshot_add_column(); +DROP PROCEDURE phase3_customs_supplier_snapshot_add_column; + CREATE TABLE IF NOT EXISTS fl_customs_receipt ( id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 4e3c9478b..a49ede7f6 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -4,13 +4,13 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', - status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照', - channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照', - warehouse_name VARCHAR(128) NOT NULL COMMENT '国内中转仓名称快照', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', + channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', + warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照', + warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', @@ -23,17 +23,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', + freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', - pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', - pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号', - pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间', - pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间', - change_reason VARCHAR(1000) NULL COMMENT '船期变更原因', - failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因', - cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因', remark VARCHAR(1000) NULL COMMENT '平台内部备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', @@ -48,14 +41,55 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN - ('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')), + ('DRAFT','RELEASED')), CONSTRAINT chk_ocean_booking_schedule CHECK - (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at), - CONSTRAINT chk_ocean_booking_pending_schedule CHECK - (pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL - OR pending_estimated_arrival_at >= pending_estimated_sailing_at) + (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. +ALTER TABLE fl_ocean_booking + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; + +-- Recreate triggers and the current two-state constraint on repeatable initialization. +DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_party_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_history_before_delete; +-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS. +SET @ocean_drop_status_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_status' +); +PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; +EXECUTE ocean_drop_status_stmt; +DEALLOCATE PREPARE ocean_drop_status_stmt; +ALTER TABLE fl_ocean_booking + MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', + MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', + ADD CONSTRAINT chk_ocean_booking_status CHECK + (status IN ('DRAFT','RELEASED')); + +SET @ocean_drop_failure_reason = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND column_name='failure_reason' +); +PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason; +EXECUTE ocean_drop_failure_stmt; +DEALLOCATE PREPARE ocean_drop_failure_stmt; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', @@ -66,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), - CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY')) ) ENGINE=InnoDB COMMENT='订舱提单关系方快照'; @@ -81,7 +115,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement ( sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', PRIMARY KEY (id), KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id), - CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0) ) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求'; @@ -96,7 +130,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation ( PRIMARY KEY (id), UNIQUE KEY uk_ocean_allocation_container (container_booking_id), KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at), - CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id) REFERENCES fl_ocean_booking_container_requirement(id), CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) @@ -116,7 +150,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', PRIMARY KEY (id), KEY idx_ocean_history_booking (ocean_booking_id,created_at,id), - CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) + CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE ) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史'; CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation ( @@ -133,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation ( PRIMARY KEY (id), UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key), KEY idx_ocean_operation_booking (ocean_booking_id,created_at), - CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希'; @@ -141,15 +175,11 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN - IF OLD.status IN ('RELEASED','CANCELLED') THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改'; + IF OLD.status = 'RELEASED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; END IF; IF OLD.status <> NEW.status AND NOT ( - (OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')) - OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')) - OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN + (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法'; END IF; END$$ @@ -186,8 +216,7 @@ BEGIN IF (SELECT ocean_booking_id FROM fl_ocean_booking_container_requirement WHERE id=NEW.requirement_id) <> NEW.ocean_booking_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱'; END IF; - IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN - ('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN + IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'RELEASED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次'; END IF; END$$ @@ -214,12 +243,9 @@ BEGIN WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit' - WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm' WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change' + WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' + WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' END) OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' @@ -239,12 +265,9 @@ INSERT INTO iam_permission VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0), (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0), - (5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0), - (5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0), - (5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0), (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0), - (5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0), - (5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0), + (5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0), + (5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0), (5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -258,5 +281,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102141; UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 4bfe14ab4..552d13600 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -28,23 +28,19 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); --- Keep the container master immutable after loading; only the two document rows remain operable. +-- Keep container identity/loading facts immutable while allowing release transport data to arrive later. DROP TRIGGER IF EXISTS trg_container_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id; - IF current_plan_status='COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status='LOADING' AND ( + IF current_plan_status IN ('LOADING','COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -52,15 +48,16 @@ BEGIN OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 1da47ad6f..00e4d9dfd 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -26,21 +26,33 @@ DELIMITER ; CALL phase3_add_allocation_picked_count(); DROP PROCEDURE phase3_add_allocation_picked_count; +CREATE TABLE IF NOT EXISTS fl_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; + CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', + record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', + merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID', + customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围', + customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', vessel_name VARCHAR(128) NULL COMMENT '船名快照', voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', - container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', @@ -51,7 +63,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', - terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + terminal_trucking_company_id BIGINT UNSIGNED NULL COMMENT '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司名称快照', loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', @@ -69,14 +82,75 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( PRIMARY KEY (id), UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), - KEY idx_container_booking_container (container_no), + UNIQUE KEY uk_container_booking_container_no (container_no), + KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), KEY idx_container_booking_voyage (voyage_no, updated_at), KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type), + CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')), + CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; +DELIMITER $$ +CREATE PROCEDURE phase3_container_real_unit_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='source_type') THEN + ALTER TABLE fl_container_booking ADD COLUMN source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' + COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜' AFTER cabinet_no; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='record_status') THEN + ALTER TABLE fl_container_booking ADD COLUMN record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' + COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史' AFTER source_type; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='merged_into_container_booking_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL + COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN + ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN + ALTER TABLE fl_container_booking ADD KEY idx_container_booking_merge (record_status,merged_into_container_booking_id); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_source') THEN + ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_source + CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_record_status') THEN + ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status + CHECK (record_status IN ('ACTIVE','MERGED')); + END IF; +END$$ +DELIMITER ; +CALL phase3_container_real_unit_columns(); +DROP PROCEDURE phase3_container_real_unit_columns; + +-- 触发器创建前先收敛旧库货柜供应商ID列;历史名称无法反推ID,保持NULL。 +DROP PROCEDURE IF EXISTS phase3_container_supplier_snapshot_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_container_supplier_snapshot_add_column() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_container_booking' + AND column_name='terminal_trucking_company_id') THEN + ALTER TABLE fl_container_booking + ADD COLUMN terminal_trucking_company_id BIGINT UNSIGNED NULL COMMENT '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空' AFTER max_weight_kg; + END IF; +END$$ +DELIMITER ; +CALL phase3_container_supplier_snapshot_add_column(); +DROP PROCEDURE phase3_container_supplier_snapshot_add_column; + CREATE TABLE IF NOT EXISTS fl_loading_plan ( id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', @@ -584,10 +658,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -596,8 +667,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -609,9 +678,9 @@ BEGIN OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -622,7 +691,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; @@ -765,7 +834,44 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN - IF NOT EXISTS ( + IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN + IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段'; + END IF; + ELSEIF NOT EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND item_status = 'PARTIAL' @@ -800,7 +906,6 @@ BEGIN OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) OR NOT (NEW.internal_remark <=> OLD.internal_remark) OR NOT (NEW.customer_remark <=> OLD.customer_remark) - OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) OR NOT (NEW.frozen_at <=> OLD.frozen_at) OR NOT (NEW.frozen_by <=> OLD.frozen_by) OR NOT (NEW.removed_by <=> OLD.removed_by) @@ -1209,7 +1314,7 @@ BEGIN WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', - 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN + 'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index 986797881..311cfa4ed 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( customer_name VARCHAR(128) NULL COMMENT '客户名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', + bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话', driver_name VARCHAR(64) NULL COMMENT '司机姓名', @@ -52,9 +53,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision) ) ENGINE=InnoDB COMMENT='拖车状态与改约历史'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( - id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定', + id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他', file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id) + uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER')) ) ENGINE=InnoDB COMMENT='拖车附件文件引用'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', @@ -83,10 +84,18 @@ BEGIN END$$ DELIMITER ; +CALL phase3_truck_add_column( + 'bill_of_lading_no', + 'VARCHAR(128) NULL COMMENT ''派车提单号快照;关联RELEASED订舱时由订舱事实源冻结'' AFTER carrier_name' +); CALL phase3_truck_add_column( 'actual_arrived_warehouse_at', 'DATETIME(3) NULL COMMENT ''车辆实际到达收货仓时间'' AFTER planned_loading_at' ); +CALL phase3_truck_add_column( + 'actual_loading_at', + 'DATETIME(3) NULL COMMENT ''实际开始装柜时间'' AFTER actual_arrived_warehouse_at' +); CALL phase3_truck_add_column( 'actual_departed_at', 'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at' diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 0c2bcffaf..660d2c356 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -55,7 +55,14 @@ paths: parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] post: summary: Submit to customs agent and freeze approved file versions in a batch snapshot - responses: {'200': {description: Submission batch}} + description: customsAgentId must identify an ACTIVE Warehouse supplier in category CUSTOMS; the persisted agent name is resolved server-side and free text cannot bypass supplier validation. + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SubmitBatchRequest'}}}} + responses: + '200': + description: Submission batch + content: + application/json: + schema: {$ref: '#/components/schemas/CustomsSubmissionBatch'} /api/platform/v1/customs/submission-batches/{batchId}/receipts: parameters: - {name: batchId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -91,3 +98,30 @@ components: in: path required: true schema: {type: string, pattern: '^[1-9][0-9]*$'} + schemas: + SubmitBatchRequest: + type: object + required: [customsAgentId, expectedVersion] + properties: + customsAgentId: {type: integer, format: int64, minimum: 1, description: ACTIVE Warehouse supplier ID validated as CUSTOMS.} + customsAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse.} + externalReference: {type: string, maxLength: 128, nullable: true} + note: {type: string, maxLength: 500, nullable: true} + expectedVersion: {type: integer, minimum: 0} + CustomsSubmissionBatch: + type: object + required: [id, customsCaseId, batchNo, batchVersion, customsAgentName, status, submittedBy, submittedAt] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + customsCaseId: {type: string, pattern: '^[1-9][0-9]*$'} + batchNo: {type: string, maxLength: 40} + batchVersion: {type: integer, minimum: 1} + customsAgentId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Server-validated CUSTOMS supplier ID snapshot; null only for non-migrated historical rows.} + customsAgentName: {type: string, maxLength: 128, description: Server-frozen Warehouse supplier name snapshot.} + externalReference: {type: string, maxLength: 128, nullable: true} + fileSnapshotJson: {type: string} + status: {type: string, enum: [SUBMITTED, ACKNOWLEDGED, REJECTED, COMPLETED]} + note: {type: string, maxLength: 500, nullable: true} + submittedBy: {type: string, pattern: '^[1-9][0-9]*$'} + submittedByName: {type: string, maxLength: 64, nullable: true} + submittedAt: {type: string, format: date-time} diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 694f8417b..98e2250fd 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -130,38 +130,94 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 - description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 + description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId、业务 customerId 或客户计费规则作为前置。区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: post: summary: 发送已确认费用快照 - description: 该动作涉及后续仓储流程,本轮二期验收不调用。 + description: 人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/latest: get: - summary: 查询最新费用快照 + summary: 查询 fl_inbound_fee 最新费用快照 + description: fl_inbound_fee 是头程运费唯一事实源;fee-details 中 sourceType=FBA_PRICING 的记录仅为最新版本只读派生。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/published: put: - summary: 调整已发布费用 - description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 WAIT_SPLIT 状态。 + summary: 调整初算或已发布费用 + description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/AdjustFeeRequest'} - responses: {'200': {description: 成功或业务失败}, '403': {description: 无调整权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无调整权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details: + get: + summary: 查询入仓费用明细 + description: 默认隐藏 publicationStatus=SUPERSEDED 的历史版本。FBA_PRICING 是 fl_inbound_fee 最新版本的只读派生;MANUAL 是独立新增、审核和版本调整的其他应收/应付。 + parameters: + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/InboundId'} + - {name: includeHistory, in: query, required: false, schema: {type: boolean, default: false}, description: true 时包含 SUPERSEDED 历史版本} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}} + post: + summary: 新增 MANUAL 其他费用 + description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}: + put: + summary: 编辑待审核 MANUAL 费用 + description: FBA_PRICING 只读派生禁止通过通用费用接口修改。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + delete: + summary: 删除待审核 MANUAL 费用 + description: FBA_PRICING 只读派生禁止删除。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve: + post: + summary: 审核通过 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject: + post: + summary: 驳回 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailRejectRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust: + post: + summary: 调整已审核 MANUAL 费用并创建新版本 + description: 原版本置为 SUPERSEDED,新记录使用相同 sourceFeeId、sourceFeeVersion+1 并重新进入 PENDING/DRAFT;FBA_PRICING 必须通过头程费用调整接口处理。 + parameters: + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/InboundId'} + - {$ref: '#/components/parameters/DetailId'} + - {name: expectedSourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void: + post: + summary: 作废已审核 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailVersionRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} @@ -170,14 +226,187 @@ components: TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}} Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + DetailId: {name: detailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + AccountId: {name: accountId, in: query, required: true, description: 目标客户 ID, schema: {type: string, pattern: '^[1-9][0-9]*$'}} VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + FeeSnapshotApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/FeeSnapshotResponse'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + FeeSnapshotResponse: + type: object + description: fl_inbound_fee 当前版本快照;自动初算为 CALCULATED,人工确认后为 CONFIRMED,只有人工发布后为 SENT。 + required: [feeId, feeNo, feeVersion, feeStatus, inboundId, totalAmount, currency] + properties: + feeId: {type: string, pattern: '^[1-9][0-9]*$'} + feeNo: {type: string} + feeVersion: {type: integer, minimum: 1} + feeStatus: {type: string, enum: [CALCULATED, CONFIRMED, SENT, SUPERSEDED]} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, nullable: true} + chargeRuleId: {type: string, nullable: true} + ruleCode: {type: string, nullable: true} + chargeRuleVersion: {type: integer, nullable: true} + carrierRateId: {type: string, nullable: true} + rateCode: {type: string, nullable: true} + carrierRateVersion: {type: integer, nullable: true} + priceBookVersionId: {type: string, nullable: true} + customerId: {type: string, nullable: true} + customerName: {type: string, nullable: true} + originWarehouseId: {type: string, nullable: true} + originWarehouseName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + carrierId: {type: string, nullable: true} + carrierName: {type: string, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + taxMode: {type: string, nullable: true} + pricingTaxMode: {type: string, nullable: true} + chargeMode: {type: string, nullable: true} + conversionFactor: {type: number, nullable: true} + roundingScale: {type: integer, nullable: true} + roundingMode: {type: string, nullable: true} + actualWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + convertedQuantity: {type: number, nullable: true} + chargeableQuantity: {type: number, nullable: true} + chargeUnit: {type: string, nullable: true} + unitPrice: {type: number, nullable: true} + subtotalAmount: {type: number, nullable: true} + additionalAmount: {type: number, nullable: true} + totalAmount: {type: number} + currency: {type: string, pattern: '^[A-Z]{3}$'} + rateEffectiveFrom: {type: string, format: date-time, nullable: true} + rateEffectiveTo: {type: string, format: date-time, nullable: true} + calculatedAt: {type: string, format: date-time, nullable: true} + confirmedAt: {type: string, format: date-time, nullable: true} + sentAt: {type: string, format: date-time, nullable: true} + nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT} + InboundFeeDetailSaveRequest: + type: object + required: [feeItemId, direction, businessDate, unitPrice, currency, amount] + properties: + feeItemId: {type: string, pattern: '^[1-9][0-9]*$'} + direction: {type: string, enum: [RECEIVABLE, PAYABLE]} + businessDate: {type: string, format: date} + unitPrice: {type: number, minimum: 0} + currency: {type: string, pattern: '^[A-Z]{3}$'} + amount: {type: number, minimum: 0} + supplierId: {type: string, nullable: true} + remark: {type: string, maxLength: 500, nullable: true} + InboundFeeDetailVersionRequest: + type: object + required: [expectedSourceVersion] + properties: {expectedSourceVersion: {type: integer, minimum: 1}} + InboundFeeDetailRejectRequest: + type: object + required: [reason] + properties: {reason: {type: string, minLength: 1, maxLength: 500}} + InboundFeeDetailResource: + type: object + description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。 + required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + feeItemId: {type: string, pattern: '^[1-9][0-9]*$'} + sourceType: {type: string, enum: [MANUAL, FBA_PRICING]} + sourceFeeId: {type: string, nullable: true, description: FBA_PRICING 对应 fl_inbound_fee.id;MANUAL V2+ 指向稳定的首版本来源 ID} + sourceFeeVersion: {type: integer, minimum: 1} + sourceFeeNo: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + direction: {type: string, enum: [RECEIVABLE, PAYABLE]} + supplierId: {type: string, nullable: true} + supplierName: {type: string, nullable: true, readOnly: true} + supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE} + businessDate: {type: string, format: date} + customerId: {type: string, nullable: true} + customerName: {type: string, nullable: true} + invoiceTitle: {type: string, nullable: true} + feeCategorySnapshot: {type: string, nullable: true} + tradeScopeSnapshot: {type: string, nullable: true} + feeTypeNameSnapshot: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + packageCount: {type: integer, nullable: true} + actualGrossWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + volumeWeightKg: {type: number, nullable: true} + chargeableWeightKg: {type: number, nullable: true} + chargeableVolumeCbm: {type: number, nullable: true} + chargeableQuantity: {type: number, nullable: true} + chargeUnit: {type: string, nullable: true} + unitPrice: {type: number} + currency: {type: string, pattern: '^[A-Z]{3}$'} + amount: {type: number} + remark: {type: string, nullable: true} + reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]} + reviewerId: {type: string, nullable: true} + reviewerName: {type: string, nullable: true} + reviewedAt: {type: string, format: date-time, nullable: true} + reviewReason: {type: string, nullable: true} + publicationStatus: {type: string, enum: [DRAFT, PUBLISHED, SUPERSEDED, VOIDED]} + publishedBy: {type: string, nullable: true} + publishedByName: {type: string, nullable: true} + publishedAt: {type: string, format: date-time, nullable: true} + inboundedAt: {type: string, format: date-time, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} + InboundFeeDetailApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundFeeDetailResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + InboundFeeDetailListApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/InboundFeeDetailResource'}} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + VoidApiResponse: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + data: {nullable: true} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} AdjustFeeRequest: type: object - required: [subtotalAmount, additionalAmount, reason] + required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: - subtotalAmount: {type: number, minimum: 0, multipleOf: 0.01} + expectedFeeVersion: {type: integer, minimum: 1} + chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} CalculateRequest: diff --git a/firstleg/src/main/resources/openapi/fee-item-v1.yaml b/firstleg/src/main/resources/openapi/fee-item-v1.yaml index 76988399a..31ba1ec1d 100644 --- a/firstleg/src/main/resources/openapi/fee-item-v1.yaml +++ b/firstleg/src/main/resources/openapi/fee-item-v1.yaml @@ -100,6 +100,7 @@ components: category: {type: string, maxLength: 64} tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]} feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'} + supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string, maxLength: 128} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} @@ -115,6 +116,7 @@ components: category: {type: string} tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]} feeCode: {type: string} + supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a1c6d1b88..2b3f9dbc9 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -49,6 +49,51 @@ paths: - {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} responses: '200': {description: 幂等补偿完成} + /internal/firstleg/v1/pricing/channels/validate: + post: + summary: 校验订单渠道并按需返回进仓编码前缀 + description: 仅供订单服务调用;AUTO 模式要求并返回编码前缀,LEGACY_MANUAL 模式仅校验渠道可用性。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string, minLength: 1, maxLength: 64} + destinationType: {type: string, enum: [FBA, OVERSEAS, PRIVATE]} + requireInboundCodePrefix: {type: boolean} + responses: + '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} + /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot: + get: + summary: Finance 按入库费用来源版本精确回查不可变快照 + description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到其他版本。 + security: + - internalServiceToken: [] + parameters: + - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + responses: + '200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误} + /internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot: + get: + summary: Finance 按柜次费用来源版本精确回查不可变快照 + description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到当前可变版本。 + security: + - internalServiceToken: [] + parameters: + - {name: containerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + responses: + '200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误} /api/platform/v1/first-leg/customer-dimension-config: get: summary: 查询客户固定四箱规尺寸配置 @@ -205,6 +250,7 @@ paths: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: containerNo, in: query, schema: {type: string, maxLength: 64}} - {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}} - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -217,16 +263,29 @@ paths: schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'} '403': {description: 无货柜查询权限} post: - summary: 新增平台货柜 - description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜) + summary: 新增内部模拟柜 + description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱 requestBody: required: true content: application/json: - schema: {$ref: '#/components/schemas/ContainerSaveRequest'} + schema: {$ref: '#/components/schemas/ContainerSimulationCreateRequest'} responses: '200': {description: 成功或重复号、参数校验等业务失败} '403': {description: 无新增按钮权限} + /api/platform/v1/containers/{id}/ocean-booking-link: + post: + summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号 + description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} + responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}} + delete: + summary: 解除内部模拟柜的真实订舱关联 + description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;原子清除当前柜订舱/运输快照和 allocation,不恢复或创建其他柜记录。任何排货、PDA、有效装柜证据或业务单证事实均拒绝。 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}} + responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}} /api/platform/v1/containers/{id}: get: summary: 查询平台货柜详情 @@ -323,6 +382,89 @@ paths: responses: '200': {description: 成功、计划不存在或计划已进入预装流程} '403': {description: 无智能装柜查询权限} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments: + post: + summary: 向预装计划新增入库单明细 + description: >- + EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task, + 校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId, + 递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时, + 锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。 + PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、 + loadingPlanId、containerBookingId 和 inbound identity;已全拣任务会因新增 PENDING item 从 AWAITING_REVIEW + 回到 IN_PROGRESS,已有证据不删除,但 final complete 门禁撤回。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}: + patch: + summary: 调整预装计划明细箱数 + description: >- + EDITING/LOADING 均可;packageCount 不得小于已拣 pickedPackageCount。成功递增 plan、计划明细和现有 PDA task 版本, + 原 inventorySnapshotToken 失效。PDA task 已完成或并发版本变化返回 A0409,事务零写入;调用方须刷新 + SmartLoadingPlan,PDA 在下一次写入前须刷新 task detail 与 inventory snapshot。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAdjustRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + delete: + summary: 删除未拣预装计划明细 + description: >- + 仅 pickedPackageCount=0 且计划/PDA task 未最终完成时允许。成功递增 plan 和 task 版本并使原库存快照失效; + 已拣、COMPLETED 或并发版本变化返回 A0409,事务零写入。PDA final complete 与本操作使用相同 task 行锁, + 因而只允许一个事务成功;失败方刷新计划、task detail 与 inventory snapshot 后再决定后续操作。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark: + patch: + summary: 修改装柜明细仓库备注 + description: >- + 需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark; + COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。 + 成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用 + canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload: + post: + summary: 推送智能装柜计划到预装清单 + description: >- + 需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号, + 只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。 + 预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。 + 智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING, + 但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。 + 推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联, + 任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPushRequest'}}} + responses: + '200': {description: 推送成功或业务失败,按响应体 code 判断} + '403': {description: 无智能装柜推送权限} /openapi/v1/inbound-measurements: post: summary: 提交一条第三方箱规测量结果和一张图片 @@ -355,12 +497,11 @@ paths: type: object required: [boxCode, length, width, height, machineNo, picture] properties: - boxCode: {type: string, maxLength: 128, example: 'RK-2085911321565003778#4-1'} + boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140001#4-1'} shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '可选;设备从Amazon FBA箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'} length: {type: number, format: decimal, minimum: 0.001} width: {type: number, format: decimal, minimum: 0.001} height: {type: number, format: decimal, minimum: 0.001} - weight: {type: number, format: decimal, minimum: 0.001, nullable: true} machineNo: {type: string, maxLength: 64} measuredAt: {type: string, format: date-time, nullable: true} picture: {type: string, description: 'data:image/jpeg;base64,...'} @@ -457,6 +598,43 @@ paths: requestId: {type: string, nullable: true} traceId: {type: string, nullable: true} '403': {description: 无查看权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement: + get: + summary: 查询入库测量详情 + description: >- + 需要 platform:firstleg:inbound:measure:view 权限。尺寸测量图片只随 items 返回, + 由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随 + weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的 + grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 + actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; + 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'} + '403': {description: 无入库测量查看权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft: + put: + summary: 保存测量草稿 + description: >- + 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementSaveDraftRequest'} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无测量编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans: post: summary: 平台模拟单箱设备扫描 @@ -482,7 +660,6 @@ paths: length: {type: number, format: double, minimum: 0.001} width: {type: number, format: double, minimum: 0.001} height: {type: number, format: double, minimum: 0.001} - weight: {type: number, format: double, minimum: 0.001, nullable: true} machineNo: {type: string, maxLength: 64} measuredAt: {type: string, format: date-time} image: {type: string, format: binary} @@ -514,20 +691,68 @@ paths: length: {type: number, format: double, minimum: 0.001} width: {type: number, format: double, minimum: 0.001} height: {type: number, format: double, minimum: 0.001} - weight: {type: number, format: double, minimum: 0.001, nullable: true} machineNo: {type: string, maxLength: 64} measuredAt: {type: string, format: date-time} image: {type: string, format: binary} responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options: + get: + summary: 查询称重可选托盘快照 + description: >- + 需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照; + 仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}} + '403': {description: 无入库测量查看或称重编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results: + post: + summary: 登记称重图片 OCR 毛重结果 + description: >- + 需要称重编辑权限。请求必须绑定 palletId;Firstleg 按入库单 warehouseId 读取 Warehouse + 权威快照并固化托盘编码、类型、容量和皮重,不信任前端托盘快照。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm: post: summary: 确认 OCR 重量 description: >- 需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要 platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值, - 分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。 + 分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG, + 使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。 + 入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -538,14 +763,48 @@ paths: application/json: schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'} responses: - '200': {description: 成功或业务失败,按响应体 code 判断} + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} '403': {description: 无确认权限,或修改重量时无编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}: + put: + summary: 人工修正 OCR 毛重 + description: >- + 需要称重编辑权限。修正值仍表示含托盘毛重;保留 OCR 原值,已确认结果修正后立即移出净重汇总并回到待确认。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CorrectWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} delete: summary: 删除称重图片及重量结果 description: >- 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 - 删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。 + 删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}} @@ -557,7 +816,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核事务提交后服务端自动执行计费、费用发布和入库编排并返回费用快照;若后续编排失败,审核结果保持已提交,使用同一 Idempotency-Key 重试将从持久化阶段继续,接口不会伪造 WAIT_SPLIT。 + description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -574,16 +833,7 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 1000} responses: - '200': {description: 成功或业务失败,按响应体 code 判断;成功 data 为 FeeSnapshotResponse 且 nextStatus=WAIT_SPLIT;失败可能表示审核已提交但自动编排尚未完成、版本冲突或费用/仓库门禁失败,不得据此推断 WAIT_SPLIT} - /api/platform/v1/inbounds/{inboundId}/measurements/reject: - post: - summary: 驳回测量 - description: 需要 firstleg:inbound:measurement-review 权限;只退回测量数据修改,已确认托盘和库位占用保持不动。 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} - responses: - '200': {description: 成功或业务失败} + '200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 @@ -607,15 +857,26 @@ paths: application/json: schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'} '403': {description: 非当前客户或无订单查看权限} - /api/platform/v1/inbounds/{inboundId}/measurements/exception-release: + /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust: post: - summary: 测量异常放行 - description: 需要 firstleg:inbound:exception-release 权限 + summary: 调整已审核柜次费用的日期和金额 + description: >- + 仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入 + PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时, + 必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的 + PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。 parameters: - - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} + - {name: accountId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: loadingPlanId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: feeId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerFeeAdjustRequest'} responses: - '200': {description: 成功或业务失败} + '200': {description: 成功时 data 为调整后柜次费用;业务冲突通过响应体 A0409 返回} + '403': {description: 无 firstleg:inbound:fee-detail:edit 权限} components: securitySchemes: bearerAuth: @@ -807,6 +1068,7 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} + requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} @@ -830,8 +1092,8 @@ components: currentHoldReason: {type: string, nullable: true} totalPackageCount: {type: integer, nullable: true} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} - actualNetWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的实测毛重 KG;只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退} + actualNetWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的货物净重 KG;只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退} actualVolumeCbm: {type: number, nullable: true} volumeWeightKg: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true} @@ -841,14 +1103,18 @@ components: inboundedAt: {type: string, format: date-time, nullable: true} expressCompanyCode: {type: string, nullable: true} expressCompanyName: {type: string, nullable: true} - loadingStatus: {type: string, nullable: true} containerNo: {type: string, nullable: true} cabinetNo: {type: string, nullable: true} vesselName: {type: string, nullable: true} voyageNo: {type: string, nullable: true} - sailingAt: {type: string, format: date-time, nullable: true} - portArrivalAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间} + actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间} + actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间} expressTrackingNo: {type: string, nullable: true} + pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true} + requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间} + createdByName: {type: string, nullable: true} createdAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true} UnifiedPreloadSummary: @@ -965,10 +1231,190 @@ components: properties: configId: {type: string, pattern: '^[1-9][0-9]*$'} createdByThisInitialization: {type: boolean} + WeightPalletSnapshot: + type: object + required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg] + properties: + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + capacityCbm: {type: number, format: double, minimum: 0.0001} + tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + CreateWeightResultRequest: + type: object + required: [fileId, palletId] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + ocrProvider: {type: string, maxLength: 64, nullable: true} + ocrRawText: {type: string, maxLength: 1000, nullable: true} + ocrWeightValue: {type: number, format: double, minimum: 0, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, maxLength: 8, nullable: true} + confidence: {type: number, format: double, minimum: 0, maximum: 1, nullable: true} + CorrectWeightResultRequest: + type: object + required: [version, adjustedWeightValue, adjustedWeightUnit, adjustmentReason] + properties: + version: {type: integer, minimum: 0} + adjustedWeightValue: {type: number, format: double, minimum: 0.001, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, enum: [KG, LB]} + adjustmentReason: {type: string, minLength: 1, maxLength: 500} + InboundWeightResult: + type: object + required: [id, fileId, palletId, palletCode, palletType, palletCapacityCbm, palletTareWeightKg, recognitionStatus, version] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + palletCapacityCbm: {type: number, format: double, minimum: 0.0001} + palletTareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + ocrProvider: {type: string, nullable: true} + ocrRawText: {type: string, nullable: true} + ocrWeightValue: {type: number, format: double, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, nullable: true} + confidence: {type: number, format: double, nullable: true} + grossWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: 确认时标准化的含托盘毛重 KG} + adjustedWeightValue: {type: number, format: double, nullable: true, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, nullable: true} + netWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: grossWeightKg - palletTareWeightKg} + recognitionStatus: {type: string, enum: [PENDING, PENDING_CONFIRM, NEEDS_MANUAL, CONFIRMED]} + adjustmentReason: {type: string, nullable: true} + adjustedBy: {type: string, nullable: true} + adjustedAt: {type: string, format: date-time, nullable: true} + confirmedBy: {type: string, nullable: true} + confirmedAt: {type: string, format: date-time, nullable: true} + version: {type: integer, minimum: 0} + InboundMeasurementItem: + type: object + description: >- + 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 + 尺寸测量原图,不得与 weightResults.fileId 混用。 + required: [id] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId} + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + itemNo: {type: string, nullable: true} + boxSpecification: {type: string, nullable: true} + packageCount: {type: integer, nullable: true} + lengthCm: {type: number, format: double, nullable: true} + widthCm: {type: number, format: double, nullable: true} + heightCm: {type: number, format: double, nullable: true} + girthCm: {type: number, format: double, nullable: true} + unitVolumeCbm: {type: number, format: double, nullable: true} + totalVolumeCbm: {type: number, format: double, nullable: true} + volumeWeightKg: {type: number, format: double, nullable: true} + intelligentImageFileId: + type: string + pattern: '^[1-9][0-9]*$' + nullable: true + description: 当前尺寸测量明细的 File 文件 ID;图片类型固定为 DIMENSION + overLimitWarning: {type: string, nullable: true} + outerLengthCm: {type: number, format: double, nullable: true} + outerWidthCm: {type: number, format: double, nullable: true} + outerHeightCm: {type: number, format: double, nullable: true} + remark: {type: string, nullable: true} + boxCode: {type: string, nullable: true} + scannedShipmentId: {type: string, nullable: true} + confirmedPackageCount: {type: integer, nullable: true} + machineNo: {type: string, nullable: true} + deviceMeasuredAt: {type: string, format: date-time, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + InboundMeasurementDetail: + type: object + description: >- + 入库测量权威详情。尺寸证据天然归属 items,称重证据天然归属 weightResults,响应不提供统一 images。 + required: [inboundId, inboundVersion, items, weightResults] + properties: + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, nullable: true} + companyName: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + processStatus: {type: string, nullable: true} + measureReviewStatus: {type: string, nullable: true} + recommendedPalletType: {type: string, nullable: true} + reviewReason: {type: string, nullable: true} + reviewerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + reviewerName: {type: string, nullable: true} + measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + sourceType: {type: string, nullable: true} + measurementStatus: {type: string, nullable: true} + inboundVersion: {type: integer, minimum: 0} + version: {type: integer, minimum: 0, nullable: true} + originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退} + originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照} + originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照} + originalPackageCount: {type: integer, nullable: true} + actualPackageCount: {type: integer, nullable: true} + actualGrossWeightKg: + type: number + format: double + nullable: true + multipleOf: 0.001 + description: 全部 CONFIRMED 称重结果 grossWeightKg 之和,含托盘毛重 KG;无确认结果时为空 + actualNetWeightKg: + type: number + format: double + nullable: true + multipleOf: 0.001 + description: 全部 CONFIRMED 称重结果 netWeightKg 之和,扣除托盘皮重后的货物净重 KG;无确认结果时为空,审核与计费以此为准 + actualVolumeCbm: {type: number, format: double, nullable: true} + volumeWeightKg: {type: number, format: double, nullable: true} + actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正} + items: + type: array + description: DIMENSION 尺寸测量明细及其图片证据 + items: {$ref: '#/components/schemas/InboundMeasurementItem'} + weightResults: + type: array + description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 + items: {$ref: '#/components/schemas/InboundWeightResult'} + InboundMeasurementSaveDraftRequest: + type: object + required: [version, items] + additionalProperties: false + properties: + measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + version: {type: integer, minimum: 0} + actualGoodsName: {type: string, maxLength: 255, nullable: true} + items: + type: array + minItems: 1 + maxItems: 500 + items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'} + InboundMeasurementItemRequest: + type: object + required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm] + additionalProperties: false + properties: + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'} + itemNo: {type: string, minLength: 1, maxLength: 32} + boxSpecification: {type: string, minLength: 1, maxLength: 64} + packageCount: {type: integer, minimum: 1, maximum: 1000000} + lengthCm: {type: number, minimum: 0.001} + widthCm: {type: number, minimum: 0.001} + heightCm: {type: number, minimum: 0.001} + intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + overLimitWarning: {type: string, maxLength: 500, nullable: true} + outerLengthCm: {type: number, minimum: 0, nullable: true} + outerWidthCm: {type: number, minimum: 0, nullable: true} + outerHeightCm: {type: number, minimum: 0, nullable: true} + remark: {type: string, maxLength: 500, nullable: true} + InboundMeasurementDetailEnvelope: + type: object + required: [code, message] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/InboundMeasurementDetail'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} ConfirmWeightResultRequest: type: object required: [version] - description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。 + description: 人工修改字段表示含托盘毛重且必须同时提供;全部省略时直接确认现有 OCR 或已修正毛重。 properties: version: {type: integer, minimum: 0} adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true} @@ -981,7 +1427,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string, nullable: true} - inboundCode: {type: string, minLength: 1, maxLength: 40, example: RK-2085308606829346818} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140001} customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} customerName: {type: string, minLength: 1} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1020,13 +1466,17 @@ components: pages: {type: string, pattern: '^[0-9]+$'} ContainerBooking: type: object - required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version] + required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, pickupOrderDocumentStatus, version] properties: id: {type: string, pattern: '^[1-9][0-9]*$'} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} - bookingNo: {type: string, maxLength: 40} + planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} + bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号} containerNo: {type: string, maxLength: 64, nullable: true} - cabinetNo: {type: string, maxLength: 64} + cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 平台稳定柜次号} + sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜} + recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回} + mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空} channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled} channelName: {type: string, maxLength: 128, nullable: true} oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} @@ -1036,7 +1486,7 @@ components: billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, maxLength: 32} + containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} shippingCompanyId: {type: string, nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -1045,9 +1495,10 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} - terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot} loadingAt: {type: string, format: date-time, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} actualSailingAt: {type: string, format: date-time, nullable: true} @@ -1056,19 +1507,27 @@ components: estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} + exceptionActive: {type: boolean, description: 最新异常历史动作是否为 MARK} + destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} + customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} + deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} + pickupOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} version: {type: integer, minimum: 0} + ContainerDocumentStatus: + type: string + enum: [MISSING, UPLOADED, COMPLETED] + description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED ContainerSaveRequest: type: object - required: [cabinetNo, warehouseId, warehouseName, containerType] + required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg] properties: containerNo: {type: string, maxLength: 64, nullable: true} - cabinetNo: {type: string, maxLength: 64} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseName: {type: string, maxLength: 128} billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, maxLength: 32} + containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -1077,9 +1536,10 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} - terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse and cannot replace the ID} loadingAt: {type: string, format: date-time, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} actualSailingAt: {type: string, format: date-time, nullable: true} @@ -1101,6 +1561,83 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500} + SmartLoadingPushRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} + SmartLoadingAssignRequest: + type: object + required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId] + properties: + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'} + packageCount: {type: integer, minimum: 1} + expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} + operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingAdjustRequest: + type: object + required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId] + properties: + packageCount: {type: integer, minimum: 1, description: 不得小于响应中的 minPackageCount} + expectedPlanVersion: {type: integer, minimum: 0} + expectedItemVersion: {type: integer, minimum: 0} + operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingDeleteRequest: + type: object + required: [expectedPlanVersion, expectedItemVersion, operationId] + properties: + expectedPlanVersion: {type: integer, minimum: 0} + expectedItemVersion: {type: integer, minimum: 0} + operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingWarehouseRemarkRequest: + type: object + required: [expectedPlanVersion, expectedItemVersion, operationId] + properties: + warehouseRemark: {type: string, nullable: true, maxLength: 1000, description: 仓库备注;null/空字符串均表示清空} + expectedPlanVersion: {type: integer, minimum: 0} + expectedItemVersion: {type: integer, minimum: 0} + operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingPlan: + type: object + required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] + properties: + id: {type: string} + planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} + status: {type: string, enum: [EDITING, LOADING, COMPLETED]} + bookingId: {type: string} + containerNo: {type: string, nullable: true} + voyageNo: {type: string, nullable: true} + version: {type: integer, minimum: 0, description: planVersion,写请求使用此值} + canAssign: {type: boolean} + canEdit: {type: boolean} + blockedReason: {type: string, nullable: true} + finalCompletionStatus: {type: string, enum: [NOT_STARTED, IN_PROGRESS, COMPLETED], description: IN_PROGRESS 表示 final complete 门禁尚未通过;增量新增 PENDING item 会保持或恢复此值,COMPLETED 后 canAssign/canEdit 均为 false} + pdaTaskId: {type: string, nullable: true} + pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量} + pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值} + items: + type: array + items: {$ref: '#/components/schemas/SmartLoadingPlanItem'} + SmartLoadingPlanItem: + type: object + required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove] + properties: + id: {type: string} + inboundId: {type: string} + inboundDetailId: {type: string} + orderNo: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + customerName: {type: string, nullable: true} + customerRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单 customerRemark} + internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark} + warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改} + packageCount: {type: integer, minimum: 1} + version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值} + pickedPackageCount: {type: integer, minimum: 0} + minPackageCount: {type: integer, minimum: 1} + canAdjust: {type: boolean} + canRemove: {type: boolean} ContainerDocumentAttachRequest: type: object required: [fileId, expectedVersion] @@ -1178,7 +1715,6 @@ components: publicationStatus: {type: string, enum: [PUBLISHED]} publishedAt: {type: string, format: date-time} inboundedAt: {type: string, format: date-time, nullable: true} - settlementIncluded: {type: boolean, example: true} InboundOrderPageEnvelope: type: object properties: @@ -1209,6 +1745,37 @@ components: companyId: {type: string, nullable: true} taxMode: {type: string, nullable: true, description: 订单报关方式快照} pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 下单时选择的报价包税维度快照} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + customerName: {type: string, nullable: true} + companyName: {type: string, nullable: true} + warehouseId: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + deliveryWarehouseId: {type: string, nullable: true} + deliveryWarehouseName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + totalPackageCount: {type: integer, nullable: true} + actualGrossWeightKg: {type: number, nullable: true} + actualNetWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + volumeWeightKg: {type: number, nullable: true} + chargeableWeightKg: {type: number, nullable: true} + chargeableVolumeCbm: {type: number, nullable: true} + palletCount: {type: integer, nullable: true} + locationCodes: {type: string, nullable: true} + measureCompletedAt: {type: string, format: date-time, nullable: true} + inboundedAt: {type: string, format: date-time, nullable: true} + expressCompanyName: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} InboundMeasurementCallbackRequest: type: object required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm] @@ -1216,7 +1783,7 @@ components: eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001} externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001} warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} - inboundCode: {type: string, maxLength: 40, example: RK202608010001} + inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140001} inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID} deviceCode: {type: string, maxLength: 64, example: DEVICE-01} measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} @@ -1225,7 +1792,6 @@ components: widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10} heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00} packageCount: {type: integer, minimum: 1, maximum: 1000000, example: 50} - unitWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, example: 20} remark: {type: string, maxLength: 500, nullable: true} InboundMeasurementEventResponse: type: object @@ -1246,3 +1812,70 @@ components: assigneeId: {type: integer, format: int64} assigneeName: {type: string, maxLength: 64} reason: {type: string, maxLength: 500} + ContainerSimulationCreateRequest: + type: object + required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg] + properties: + channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道} + warehouseId: {type: integer, format: int64, minimum: 1} + containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型} + capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} + maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重} + ContainerOceanBookingLinkRequest: + type: object + required: [oceanBookingId, requirementId, containerNo, expectedVersion] + properties: + oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID} + requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID} + containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号} + expectedVersion: {type: integer, minimum: 0} + ContainerOceanBookingUnlinkRequest: + type: object + required: [expectedVersion, reason] + properties: + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 500} + ContainerFeeAdjustRequest: + type: object + additionalProperties: false + required: [expectedSourceVersion, businessDate, amount] + properties: + expectedSourceVersion: {type: integer, minimum: 1} + businessDate: {type: string, format: date} + amount: {type: number, minimum: 0} + FinanceFeeSourceSnapshot: + type: object + description: Finance 消费的版本化费用来源快照;supplierCategory 为空表示仅约束供应商 ACTIVE,非空才约束明确类别。 + required: [tenantId, sourceType, feeId, feeVersion, customerId, customerName, feeItemName, direction, businessDate, currency, totalAmount, reviewStatus, publicationStatus, sourceHash, snapshotVersion] + properties: + tenantId: {type: integer, format: int64} + sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]} + feeId: {type: integer, format: int64} + feeVersion: {type: integer, minimum: 1} + inboundId: {type: integer, format: int64, nullable: true} + inboundCode: {type: string, nullable: true} + orderId: {type: integer, format: int64, nullable: true} + containerId: {type: integer, format: int64, nullable: true} + containerBookingId: {type: integer, format: int64, nullable: true} + containerNo: {type: string, nullable: true} + customerId: {type: integer, format: int64} + customerName: {type: string} + supplierId: {type: integer, format: int64, nullable: true} + supplierName: {type: string, nullable: true} + supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE} + feeItemId: {type: integer, format: int64, nullable: true} + feeItemCode: {type: string, nullable: true} + feeItemName: {type: string} + direction: {type: string, enum: [RECEIVABLE, PAYABLE]} + businessDate: {type: string, format: date} + currency: {type: string, enum: [CNY]} + totalAmount: {type: number, minimum: 0} + reviewStatus: {type: string, enum: [APPROVED]} + publicationStatus: {type: string, enum: [PUBLISHED]} + publishedAt: {type: string, format: date-time, nullable: true} + chargeableQuantity: {type: number, nullable: true} + chargeUnit: {type: string, nullable: true} + actualGrossWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + sourceHash: {type: string} + snapshotVersion: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 23c3b10a1..59c5fbc0d 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,16 +7,17 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 默认返回全部仓库的装柜计划;可按仓库筛选,客户账号仅用于展示货物归属。 + description: 默认返回全部仓库的装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - name: status in: query description: 预装状态 - schema: {type: string, enum: [LOADING]} + schema: {type: string, enum: [EDITING, LOADING]} - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -33,7 +34,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/transport: patch: summary: 修改预装运输信息 - description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。 + description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。 x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} @@ -46,15 +47,27 @@ paths: schema: {$ref: '#/components/schemas/TransportUpdateRequest'} responses: '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/confirm: + post: + summary: 原子确认整份预装计划 + description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 + x-permission: platform:firstleg:preload:edit + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}} + responses: + '200': {$ref: '#/components/responses/PreloadConfirmationResponse'} /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 默认返回全部仓库的 COMPLETED 装柜计划;可按仓库筛选,包含全部客户单据。 + description: 默认返回全部仓库的 COMPLETED 装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -138,6 +151,12 @@ components: in: query description: 按预装单号、集装箱号或柜号模糊查询 schema: {type: string, maxLength: 128} + CustomerId: + name: customerId + in: query + required: false + description: 仅返回包含该客户货物的清单 + schema: {$ref: '#/components/schemas/LongId'} WarehouseId: name: warehouseId in: query @@ -167,6 +186,9 @@ components: PreloadDetailResponse: description: 预装清单详情查询或操作成功,也可能返回业务失败 content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}} + PreloadConfirmationResponse: + description: 计划级预装确认成功、精确重放或业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadConfirmationEnvelope'}}} ConfirmedPageResponse: description: 装柜清单分页查询成功或业务失败,按响应体 code 判断 content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}} @@ -210,6 +232,14 @@ components: data: allOf: [{$ref: '#/components/schemas/PreloadDetail'}] nullable: true + PreloadConfirmationEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadConfirmation'}] + nullable: true ConfirmedPageEnvelope: allOf: - {$ref: '#/components/schemas/ApiEnvelope'} @@ -260,37 +290,72 @@ components: customerName: {type: string, nullable: true, description: 客户名称} PreloadSummary: type: object - required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version] + required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version, readiness] properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - preloadNo: {type: string, description: 预装单号} + preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用} containerNo: {type: string, nullable: true, description: 集装箱号} - internalContainerNo: {type: string, nullable: true, description: 内部柜号} + cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号;不得与真实集装箱号混用} customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} vesselName: {type: string, nullable: true, description: 船名} voyageNo: {type: string, nullable: true, description: 航次} containerType: {type: string, nullable: true, description: 货柜类型} - status: {type: string, enum: [LOADING], description: 装柜状态} + status: {type: string, enum: [EDITING, LOADING], description: 装柜计划状态} skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数} totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} totalVolumeCbm: {type: number, description: 总体积,单位立方米} totalGrossWeightKg: {type: number, description: 总毛重,单位千克} createdAt: {type: string, format: date-time, description: 创建时间} version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + readiness: {$ref: '#/components/schemas/PreloadReadiness'} + PreloadReadiness: + type: object + required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] + properties: + preloadPushed: {type: boolean, description: 是否已完成 push-preload} + containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo} + preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数} + confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数} + oceanBookingId: {$ref: '#/components/schemas/NullableLongId'} + oceanBookingStatus: {type: string, nullable: true, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED], description: 真实订舱当前状态} + pdaTaskId: {$ref: '#/components/schemas/NullableLongId'} + pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态} + confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态} + canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口} + readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;task 已创建后仍为 true} + blockingReasons: + type: array + description: 阻止计划确认的稳定原因码 + items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} + ConfirmPreloadRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version} + PreloadConfirmation: + type: object + required: [planId, containerBookingId, confirmationStatus, version, readiness] + properties: + planId: {$ref: '#/components/schemas/LongId'} + containerBookingId: {$ref: '#/components/schemas/LongId'} + confirmationStatus: {type: string, enum: [CONFIRMED]} + version: {type: integer, format: int32, minimum: 0, description: 确认后的装柜计划 version} + readiness: {$ref: '#/components/schemas/PreloadReadiness'} ConfirmedSummary: type: object required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version] properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - loadingListNo: {type: string, description: 装柜清单号} + loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号} warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} containerNo: {type: string, nullable: true, description: 集装箱号} - internalContainerNo: {type: string, nullable: true, description: 内部柜号} + cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号} status: {type: string, enum: [COMPLETED], description: 装柜清单状态} totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} totalGrossWeightKg: {type: number, description: 总毛重,单位千克} @@ -304,14 +369,17 @@ components: version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} LoadingItem: type: object - required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence] + required: [itemId, accountId, orderId, orderNo, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence, itemVersion] properties: itemId: {$ref: '#/components/schemas/LongId'} accountId: {$ref: '#/components/schemas/LongId'} customerName: {type: string, nullable: true, description: 客户名称快照} + orderId: {$ref: '#/components/schemas/LongId'} + orderNo: {type: string, description: 订单号快照} inboundId: {$ref: '#/components/schemas/LongId'} inboundDetailId: {$ref: '#/components/schemas/LongId'} inboundCode: {type: string, nullable: true, description: 入仓码} + containerNo: {type: string, nullable: true, description: 当前 ACTIVE 柜次的真实集装箱号} skuCode: {type: string, nullable: true, description: SKU 编码快照} destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照} chineseName: {type: string, nullable: true, description: 中文品名快照} @@ -333,6 +401,7 @@ components: warehouseRemark: {type: string, nullable: true, description: 仓库备注} loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序} frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间} + itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion} OperationHistory: type: object required: [historyId, actionType, operatorId, occurredAt] diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8705ab8b1..363e22b73 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -1,74 +1,108 @@ openapi: 3.0.3 -info: {title: Phase 3 Ocean Booking API, version: 1.1.0} +info: {title: Ocean Booking API, version: 2.0.0} paths: /api/platform/v1/ocean-bookings: + get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}} post: - summary: Create an ocean booking draft with immutable channel and warehouse snapshots + summary: Create an editable DRAFT + description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created. parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}} - responses: {'200': {description: Draft detail}} - /api/platform/v1/ocean-bookings/{oceanBookingId}/confirm: - post: - summary: Confirm booking and atomically create one stable cabinet and editing loading plan per requested quantity + responses: {'200': {description: DRAFT detail}} + /api/platform/v1/ocean-bookings/{oceanBookingId}: + get: {summary: Get booking detail, parameters: [{$ref: '#/components/parameters/OceanBookingId'}], responses: {'200': {description: Detail}}} + put: + summary: Replace a DRAFT + parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateRequest'}}}} + responses: {'200': {description: Updated DRAFT}} + delete: + summary: Delete a DRAFT; RELEASED is immutable parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} - responses: {'200': {description: CONFIRMED detail; no real container number required}} + responses: {'200': {description: Deleted}} /api/platform/v1/ocean-bookings/{oceanBookingId}/release: post: - summary: Confirm released transport data and synchronize associated cabinet transport snapshots - description: Does not create cabinets, change channels, replace cabinetNo, or recreate loading plans. + summary: Validate and permanently release a DRAFT + description: Release validates the persisted aggregate and never requires or creates a real container number. parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} + /api/platform/v1/ocean-bookings/channels: + get: + summary: List ACTIVE channels for an origin warehouse + description: Requires platform:firstleg:ocean-booking:list. Channel names are resolved from the current ACTIVE price book and frozen by the server on save. + parameters: + - {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: Channel options + content: + application/json: + schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}} components: parameters: OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: - CreateRequest: - type: object - required: [expectedVersion, data] - properties: - expectedVersion: {type: integer, enum: [0]} - data: {$ref: '#/components/schemas/DraftData'} + CreateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, enum: [0]}, data: {$ref: '#/components/schemas/DraftData'}}} + UpdateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, minimum: 0}, data: {$ref: '#/components/schemas/DraftData'}}} DraftData: type: object - required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements] + description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate. properties: - customerId: {type: integer, format: int64, minimum: 1} - customerName: {type: string, maxLength: 128} - channelCode: {type: string, maxLength: 64} - channelName: {type: string, maxLength: 128} - warehouseId: {type: integer, format: int64, minimum: 1} - warehouseName: {type: string, maxLength: 128} - billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - estimatedSailingAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release} - estimatedArrivalAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release} - parties: {type: array, minItems: 3, items: {type: object}} - containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}} - ContainerRequirement: + customerId: {type: integer, format: int64, nullable: true} + customerName: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + warehouseId: {type: integer, format: int64, nullable: true} + billOfLadingNo: {type: string, nullable: true} + shippingCompanyId: {type: integer, format: int64, nullable: true} + shippingCompanyName: {type: string, nullable: true} + bookingAgentId: + type: integer + format: int64 + nullable: true + description: Warehouse supplier ID; release validates ACTIVE category BOOKING; booking suppliers do not use domestic/international scope. + bookingAgentName: + type: string + nullable: true + description: Read-only consistency hint; the persisted snapshot always comes from Warehouse. + vesselName: {type: string, nullable: true} + voyageNo: {type: string, nullable: true} + departurePortCode: {type: string, nullable: true} + departurePortName: {type: string, nullable: true} + destinationPortCode: {type: string, nullable: true} + destinationPortName: {type: string, nullable: true} + placeOfDelivery: {type: string, nullable: true} + serviceTerm: {type: string, nullable: true} + freightTerm: {type: string, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true} + estimatedArrivalAt: {type: string, format: date-time, nullable: true} + remark: {type: string, nullable: true} + parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} + containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} + PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}} + ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}} + ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} + ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} + ChannelOption: type: object - required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity] + required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes] properties: - containerType: {type: string, maxLength: 32} - containerKind: {type: string, maxLength: 32} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0} - quantity: {type: integer, minimum: 1} - ActionRequest: + priceBookVersionId: {type: integer, format: int64} + channelCode: {type: string} + channelName: {type: string} + priceModel: {type: string} + pricingTaxModes: {type: array, items: {type: string}} + Detail: type: object - required: [expectedVersion] - properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000}} - ReleaseRequest: - type: object - required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt] + required: [oceanBooking, parties, containerRequirements, containers, histories] properties: - expectedVersion: {type: integer, minimum: 0} - billOfLadingNo: {type: string, maxLength: 128} - vesselName: {type: string, maxLength: 128} - voyageNo: {type: string, maxLength: 64} - estimatedSailingAt: {type: string, format: date-time} - estimatedArrivalAt: {type: string, format: date-time} - reason: {type: string, maxLength: 1000} + oceanBooking: + type: object + properties: + bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号} + parties: {type: array} + containerRequirements: {type: array} + containers: {type: array, description: Existing containers allocated after port pickup linking} + histories: {type: array} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 3f262804d..2b7420a4d 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -11,6 +11,12 @@ paths: parameters: [{$ref: '#/components/parameters/ContainerId'}] post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}} delete: {operationId: cancelContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}} + /api/platform/v1/containers/{id}/pda/photos/{fileId}/download: + get: + operationId: downloadContainerPdaPhoto + description: '权限:platform:firstleg:container:list;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: photo bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: photo missing or not owned by the container}} /api/platform/v1/containers/{id}/documents/{documentType}: get: operationId: getContainerDocument @@ -49,10 +55,10 @@ components: exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} - Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}} - PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} + Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}} + PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} - Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string}, downloadPath: {type: string}, uploadedAt: {type: string, format: date-time}}} + Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index b98340959..60e832d45 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -6,11 +6,13 @@ paths: /api/platform/v1/inbounds/{inboundId}/loading/evidence/pda: get: summary: List loading evidence for the active PDA worker + description: Requires the associated inbound to have entered the loading stage. PDA workflowStatus AWAITING_REVIEW does not bypass this gate; canonical claim or picking advances WAIT_SPLIT to LOADING. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}] responses: '200': {description: Business envelope; requires platform:firstleg:pda:work} post: summary: Append one PDA-captured loading evidence photo + description: Requires the associated inbound process status to be LOADING. PDA workflowStatus AWAITING_REVIEW is a review projection and does not replace the inbound loading-stage gate. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -33,6 +35,13 @@ paths: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: '200': {description: Business envelope; ownership, type and version failures use business error codes} + /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: + get: + summary: Download one loading evidence photo for the active PDA worker + description: Uses the existing platform:firstleg:pda:work authority and validates customer, inbound and evidence ownership before proxying File service content. + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/TenantId'}] + responses: + '200': {description: Authenticated image bytes} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda: post: summary: Void an incorrect PDA loading evidence photo @@ -85,7 +94,8 @@ paths: '200': {description: Business envelope containing task or null} /api/platform/v1/inbounds/{inboundId}/loading/complete: post: - summary: Complete loading only after picking and five evidence types each have an active photo + summary: Compatibility endpoint for completing loading after the canonical PDA confirmation + description: Canonical PDA confirmation atomically completes task, plan and inbound LOADED. This endpoint remains an idempotent server-side completion primitive; RELEASED booking, completed plan/picking and five ACTIVE evidence types remain hard gates. Truck status is not a prerequisite; the canonical PDA action completes the unique active appointment for the stable container booking when one exists. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -100,6 +110,19 @@ components: EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + EvidenceResponse: + type: object + required: [id, inboundId, tenantId, evidenceType, fileId, previewUrl, version, status] + properties: + id: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} + fileId: {type: integer, format: int64} + fileName: {type: string} + previewUrl: {type: string, description: Authenticated PDA image-content path; request with the current bearer token} + version: {type: integer} + status: {type: string} UpsertEvidenceRequest: type: object required: [expectedVersion, evidenceType, fileId] diff --git a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml index 65a66d892..37506b612 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml @@ -2,6 +2,7 @@ openapi: 3.0.3 info: title: Phase 3 Preload and Container Loading Bridge API version: 1.0.0 + description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before any PDA task creation; push-preload never requires containerNo and never advances LOADING. LOADING plans cannot be repaired through post-hoc binding. paths: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans: get: @@ -12,7 +13,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}: post: summary: Atomically link an inbound preload and a container loading plan - description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. + description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. This endpoint never permits post-hoc binding after the loading plan enters LOADING. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index 31a3042c9..db431dac1 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -11,7 +11,14 @@ paths: - {$ref: '#/components/parameters/InboundId'} - {$ref: '#/components/parameters/ContainerBookingId'} responses: - '200': {description: Business envelope containing matching aggregates} + '200': + description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + content: + application/json: + schema: + type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} /api/platform/v1/first-leg/outbound-dispatches/detail: get: summary: Query one aggregate and its unified timeline @@ -20,7 +27,17 @@ paths: - {$ref: '#/components/parameters/InboundId'} - {$ref: '#/components/parameters/ContainerBookingId'} responses: - '200': {description: Business envelope containing aggregate detail} + '200': + description: Business envelope containing aggregate detail + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} /api/platform/v1/first-leg/outbound-dispatches/check-gate: get: summary: Evaluate the fail-closed domestic outbound gate @@ -59,6 +76,32 @@ components: ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + Aggregate: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + properties: + orderId: {type: integer, format: int64} + orderNo: {type: string} + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + tenantId: {type: integer, format: int64} + customerId: {type: integer, format: int64, nullable: true} + customerName: {type: string, nullable: true} + inboundStatus: {type: string} + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: 稳定柜次号} + containerNo: {type: string, nullable: true, description: 真实集装箱号} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + pickTaskId: {type: integer, format: int64, nullable: true} + pickTaskStatus: {type: string, nullable: true} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true} + truckAppointmentStatus: {type: string, nullable: true} + dispatchStatus: {type: string} + version: {type: integer, minimum: 0} + gate: {type: object} ActionRequest: type: object required: [inboundId, containerBookingId, expectedVersion] diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index aebdd7383..eef1c0492 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -7,7 +7,6 @@ paths: get: summary: Find the active container picking task in one platform warehouse parameters: - - {name: containerNo, in: query, required: false, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}} - {name: cabinetNo, in: query, required: false, schema: {type: string}} - {name: loadingPlanId, in: query, required: false, schema: {type: integer, format: int64}} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -22,7 +21,7 @@ paths: - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} responses: '200': - description: Unassigned tasks and tasks assigned to the current operator; no customer selector required + description: Unassigned tasks and tasks assigned to the current operator, including AWAITING_REVIEW tasks with zero unresolved items so PDA can enter evidence and final completion; no customer selector required content: application/json: schema: @@ -35,10 +34,13 @@ paths: - {$ref: '#/components/parameters/TaskId'} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} responses: - '200': {description: Task with current transport display fields} + '200': + description: Task with current transport display and ACTIVE container lifecycle fields + content: {application/json: {schema: {$ref: '#/components/schemas/Task'}}} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim: post: summary: Atomically claim an unassigned task for the authenticated operator + description: Claiming the canonical PDA task idempotently starts its plan and advances every associated inbound from WAIT_SPLIT to LOADING with optimistic locking; workflowStatus may later become AWAITING_REVIEW while the task remains LOADING until manual completion. parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: required: true @@ -67,6 +69,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/pick: post: summary: Increment a picked item owned by the authenticated assignee + description: The first successful pick also repairs the same WAIT_SPLIT to LOADING transition when the plan was already started, without using containerNo as an association key. parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] responses: {'200': {description: Updated task}} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/partial: @@ -82,6 +85,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/batch: post: summary: Atomically submit the complete online PDA draft once + description: A batch containing PICK or PARTIAL idempotently advances associated inbound process status to LOADING before evidence operations become available. parameters: - {$ref: '#/components/parameters/TaskId'} - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} @@ -102,7 +106,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete: post: summary: PDA拣货全部处理后人工确认装柜完成 - description: PDA拣货只锁定数量和仓储资源,本接口执行结算、释放资源并生成装柜清单。 + description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。 parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: required: true @@ -122,13 +126,19 @@ components: schemas: Task: type: object - required: [id, loadingPlanId, containerBookingId, cabinetNo, taskStatus, workflowStatus, version, items] + required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] properties: id: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64} containerBookingId: {type: integer, format: int64} + inboundId: {type: integer, format: int64, description: Stable inbound identity selected from the task item; evidence identity is taskId+inboundId+loadingPlanId+containerBookingId} + inboundVersion: {type: integer, minimum: 0, description: Current fl_inbound_order.version for inboundId; never inferred from PDA task version} + loadingVersion: {type: integer, minimum: 0, description: Compatibility alias of inboundVersion from the same fl_inbound_order row; /loading/complete uses this inbound loading-stage version} cabinetNo: {type: string} currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; changing it never resynchronizes the task or item versions and it is never task identity} + sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity} + recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan} + mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history} taskStatus: {type: string, enum: [LOADING, COMPLETED]} workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]} version: {type: integer, minimum: 0} @@ -159,17 +169,20 @@ components: version: {type: integer, minimum: 0} PendingTask: type: object - required: [taskId, loadingPlanId, containerBookingId, cabinetNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount] + required: [taskId, loadingPlanId, containerBookingId, cabinetNo, sourceType, recordStatus, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount] properties: taskId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64} containerBookingId: {type: integer, format: int64} cabinetNo: {type: string} currentContainerNo: {type: string, nullable: true} + sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]} + recordStatus: {type: string, enum: [ACTIVE], description: Enforced by the server-side pending-task query; PDA clients must not infer or relax this filter} + mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task} warehouseId: {type: integer, format: int64} warehouseName: {type: string, nullable: true} taskStatus: {type: string, enum: [LOADING]} - workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS]} + workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW} version: {type: integer, minimum: 0} assignmentVersion: {type: integer, minimum: 0} itemCount: {type: integer, minimum: 1} @@ -195,7 +208,6 @@ components: warehouseId: {type: integer, format: int64, minimum: 1} loadingPlanId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1} - containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency} expectedVersion: {type: integer, minimum: 0} inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64} items: diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 04c6ec185..4a953b89d 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -1,28 +1,41 @@ openapi: 3.0.3 info: title: Truck Appointment API - version: 1.1.0 + version: 1.2.0 + description: Truck appointments are independent operational records. Ocean booking, container booking and loading plan are optional links. Draft creation/editing does not require a bill of lading or attachment; DISPATCH requires a nonblank billOfLadingNo and at least one active BILL_OF_LADING attachment owned by the appointment customer. When linked to a RELEASED booking, the server freezes and validates the booking bill of lading snapshot. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion. paths: /api/platform/v1/truck-appointments: get: summary: Query truck appointments + parameters: + - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: - '200': {description: Paged truck appointments} + '200': + description: Paged truck appointments with stable booking/container snapshots and attachment count + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} post: - summary: Create a truck appointment linked to a released ocean booking + summary: Create an independent truck appointment with optional fulfillment links requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment} + '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail} + '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -76,25 +89,29 @@ paths: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment + description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references} + '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope + description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference} + '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: summary: Upload and reference a truck appointment attachment + description: 'Requires platform:firstleg:truck-appointment:edit.' requestBody: required: true content: multipart/form-data: schema: type: object - required: [file] + required: [attachmentType, file] properties: - attachmentType: {type: string, maxLength: 32} + attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH} file: {type: string, format: binary} responses: '200': {description: Uploaded attachment reference} @@ -104,6 +121,7 @@ paths: - {$ref: '#/components/parameters/AttachmentId'} delete: summary: Delete an attachment reference owned by the appointment + description: 'Requires platform:firstleg:truck-appointment:edit. Deletes the appointment reference only; the File object is retained.' responses: '200': {description: Reference deleted} components: @@ -120,6 +138,38 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} schemas: + DetailEnvelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + allOf: [{$ref: '#/components/schemas/DetailStableReferences'}] + nullable: true + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + AttachmentRequest: + type: object + required: [fileId, attachmentType] + properties: + fileId: {type: integer, format: int64, minimum: 1} + attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH} + fileName: {type: string, maxLength: 255, description: Ignored; authoritative metadata comes from File service} + fileHash: {type: string, maxLength: 128, description: Ignored; authoritative metadata comes from File service} + Attachment: + type: object + properties: + id: {type: integer, format: int64} + appointmentId: {type: integer, format: int64} + fileId: {type: integer, format: int64} + attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER]} + fileName: {type: string} + fileHash: {type: string} + visibility: {type: string, enum: [PLATFORM]} + uploadedBy: {type: integer, format: int64} + uploadedAt: {type: string, format: date-time} ActionRequest: type: object required: [expectedVersion] @@ -128,10 +178,41 @@ components: reason: {type: string, maxLength: 500} SaveRequest: type: object - required: [oceanBookingId, plannedLoadingAt, expectedVersion] + required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: - oceanBookingId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} - loadingPlanId: {type: integer, format: int64, minimum: 1} + outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} + warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} + customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} + customerName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; persisted value is resolved from IAM customer master data.} + carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.} + carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} + billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional on draft; required before DISPATCH and server-frozen from a linked RELEASED booking when present.} plannedLoadingAt: {type: string, format: date-time} - expectedVersion: {type: integer, minimum: 0} + expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} + DetailStableReferences: + type: object + required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount] + description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment. + properties: + appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001} + containerBookingId: {type: integer, format: int64, nullable: true} + cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001} + containerNo: {type: string, nullable: true} + loadingPlanId: {type: integer, format: int64, nullable: true} + warehouseId: {type: integer, format: int64, nullable: true} + warehouseName: {type: string, nullable: true} + customerId: {type: integer, format: int64} + customerName: {type: string, description: Frozen from ACTIVE IAM customer master data; request input cannot override it} + shippingCompanyId: {type: integer, format: int64, nullable: true} + shippingCompanyName: {type: string, nullable: true} + bookingAgentId: {type: integer, format: int64, nullable: true} + bookingAgentName: {type: string, nullable: true} + carrierId: {type: integer, format: int64} + carrierName: {type: string} + billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.} + attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java new file mode 100644 index 000000000..a8e719c65 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +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; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class IamCustomerSnapshotClientContractTest { + @Test + void usesInternalIamCustomerQueryWithTokenAndExactCustomerFilter() throws Exception { + FeignClient annotation = IamCustomerSnapshotClient.class.getAnnotation(FeignClient.class); + assertThat(annotation.path()).isEqualTo("/internal/iam"); + Method method = IamCustomerSnapshotClient.class.getDeclaredMethod("page", String.class, + int.class, int.class, String.class, String.class, Long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/finance-customers"); + assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(((RequestParam) method.getParameterAnnotations()[4][0]).value()).isEqualTo("status"); + assertThat(((RequestParam) method.getParameterAnnotations()[5][0]).value()).isEqualTo("customerId"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java new file mode 100644 index 000000000..1bcd9995c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehousePalletLocationClientContractTest { + @Test + void consumesTheWarehousePalletSnapshotContractExactly() throws Exception { + FeignClient client = WarehousePalletLocationClient.class.getAnnotation(FeignClient.class); + assertThat(client.path()).isEqualTo("/internal/warehouse/v1"); + + Method method = WarehousePalletLocationClient.class.getDeclaredMethod( + "palletSnapshots", String.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/pallets/snapshots"); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value()) + .isEqualTo("warehouseId"); + + var snapshot = new WarehousePalletLocationClient.PalletSnapshot( + 101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"), + new java.math.BigDecimal("18.500")); + assertThat(snapshot).extracting( + WarehousePalletLocationClient.PalletSnapshot::palletId, + WarehousePalletLocationClient.PalletSnapshot::palletCode, + WarehousePalletLocationClient.PalletSnapshot::palletType, + WarehousePalletLocationClient.PalletSnapshot::capacityCbm, + WarehousePalletLocationClient.PalletSnapshot::tareWeightKg) + .containsExactly(101L, "PLT-101", "STANDARD", + new java.math.BigDecimal("2.0000"), new java.math.BigDecimal("18.500")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java new file mode 100644 index 000000000..8177aabe5 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +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; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseSupplierClientContractTest { + @Test + void usesWarehouseInternalSupplierEndpointAndToken() throws Exception { + FeignClient annotation = WarehouseSupplierClient.class.getAnnotation(FeignClient.class); + assertThat(annotation.path()).isEqualTo("/internal/warehouse/v1/suppliers"); + Method method = WarehouseSupplierClient.class.getDeclaredMethod("get", String.class, long.class, + boolean.class, String.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{supplierId}"); + assertThat(method.getParameterAnnotations()[0][0]).isInstanceOf(RequestHeader.class); + assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(method.getParameterAnnotations()[3][0]).isInstanceOf(RequestParam.class); + assertThat(method.getParameterCount()).isEqualTo(4); + } + + @Test + void openApiDocumentsServerResolvedSupplierSnapshots() throws Exception { + String ocean = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"), StandardCharsets.UTF_8); + String truck = Files.readString(Path.of("src/main/resources/openapi/truck-appointments-v1.yaml"), StandardCharsets.UTF_8); + String customs = Files.readString(Path.of("src/main/resources/openapi/customs-v1.yaml"), StandardCharsets.UTF_8); + assertThat(ocean).contains("bookingAgentId:", "category BOOKING", + "do not use domestic/international scope", "persisted snapshot always comes from Warehouse") + .doesNotContain("BOOKING with INTERNATIONAL scope"); + assertThat(truck).contains("carrierId:", "TRUCKING", "persisted snapshot comes from Warehouse"); + assertThat(customs).contains("customsAgentId", "CUSTOMS", "free text cannot bypass supplier validation"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 84a04362a..d94c7e2b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; @@ -19,10 +21,13 @@ class ContainerControllerContractTest { assertPermission("page", "platform:firstleg:container:list"); assertPermission("detail", "platform:firstleg:container:list"); assertPermission("create", "platform:firstleg:container:create"); + assertPermission("linkOceanBooking", "platform:firstleg:container:edit"); + assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("overview", "platform:firstleg:container:list"); + assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); assertPermission("markException", "platform:firstleg:container:edit"); assertPermission("cancelException", "platform:firstleg:container:edit"); assertPermission("logisticsDocument", "platform:firstleg:container:list"); @@ -36,7 +41,10 @@ class ContainerControllerContractTest { assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", - "downloadPath:", "version: {type: integer, minimum: 0}") + "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", + "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", + "downloadPath: {type: string, nullable: true", + "仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空") .doesNotContain("?containerNo", "containerNo in: query"); } @@ -50,6 +58,14 @@ class ContainerControllerContractTest { assertThat(section) .contains("/api/platform/v1/containers/{id}/transport:") + .contains("/api/platform/v1/containers/{id}/ocean-booking-link:") + .contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜") + .contains("RELEASED oceanBookingId") + .contains("未满配 requirementId") + .contains("唯一 containerNo") + .contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜") + .contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝") + .doesNotContain("realContainerBookingId") .contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("multipart/form-data") .contains("ContainerDocumentUploadRequest") @@ -60,6 +76,19 @@ class ContainerControllerContractTest { .contains("COMPLETED") .doesNotContain("tenantId") .doesNotContain("accountId"); + assertThat(openapi) + .contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") + .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") + .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") + .contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]") + .contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true"); + } + + @Test + @DisplayName("multipart 文本字段使用请求参数绑定") + void multipartScalarFieldsUseRequestParameters() { + assertMultipartBinding("uploadLogisticsDocument", 4, 5, 6); + assertMultipartBinding("uploadClearance", 3, 4, 5); } private static void assertPermission(String methodName, String authority) { @@ -70,4 +99,16 @@ class ContainerControllerContractTest { .isEqualTo("hasAuthority('" + authority + "')"); } + private static void assertMultipartBinding(String methodName, int fileIndex, + int versionIndex, int reasonIndex) { + Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getParameters()[fileIndex].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestPart.class)).isNull(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index caa9d096f..956e14f4a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -36,7 +36,7 @@ class FbaPricingContractTest { } @Test - @DisplayName("契约声明二期边界计费、已发布费用调整和幂等头") + @DisplayName("契约声明二期边界计费、人工费用调整和幂等头") void documentsPricingLifecycle() throws Exception { String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); @@ -49,10 +49,15 @@ class FbaPricingContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") - .contains("该动作涉及后续仓储流程,本轮二期验收不调用") + .contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域") + .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") + .contains("仅此动作将费用置为 SENT") + .contains("写入 Finance Outbox") .contains("不回退 WAIT_SPLIT 状态") - .doesNotContain("应收") - .doesNotContain("应付") + .contains("expectedFeeVersion") + .contains("chargeableQuantity") + .contains("unitPrice") + .doesNotContain("required: [subtotalAmount, additionalAmount, reason]") .doesNotContain("待客户确认"); String customerRules = openapi.substring( openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java index 39d91aa30..4df8b7ac2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java @@ -41,14 +41,25 @@ class FeeManagementContractTest { } @Test - @DisplayName("柜次费用新增和调整请求均携带往来单位快照") - void containerFeeRequestsCarryCounterpartySnapshot() { - for (Class requestType : List.of( - com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest.class, - com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class)) { - assertThat(Arrays.stream(requestType.getRecordComponents()).map(RecordComponent::getName)) - .contains("partnerId", "partnerName"); - } + @DisplayName("柜次费用调整只能修改版本、业务日期和金额") + void containerFeeAdjustmentCannotReplaceSourceIdentity() { + assertThat(Arrays.stream(com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class + .getRecordComponents()).map(RecordComponent::getName)) + .containsExactly("expectedSourceVersion", "businessDate", "amount"); + } + + @Test + @DisplayName("柜次费用调整OpenAPI冻结三字段请求和快照不漂移语义") + void containerFeeAdjustmentOpenApiIsNarrowAndVersioned() throws Exception { + String source = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(source) + .contains("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:") + .contains("ContainerFeeAdjustRequest:") + .contains("required: [expectedSourceVersion, businessDate, amount]") + .contains("additionalProperties: false") + .contains("费用方向、费用项目、供应商、币种和备注保持原快照") + .contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入"); } @Test @@ -96,6 +107,13 @@ class FeeManagementContractTest { assertThat(stringMap(properties(schemas, "FeeItemSaveRequest").get("feeCode"))) .containsEntry("maxLength", 50) .containsEntry("pattern", "^[A-Za-z0-9_-]+$"); + Map supplierCategory = stringMap( + properties(schemas, "FeeItemSaveRequest").get("supplierCategory")); + assertThat(supplierCategory) + .containsEntry("nullable", true) + .containsEntry("enum", List.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE")); + assertThat(list(stringMap(schemas.get("FeeItemSaveRequest")).get("required"))) + .doesNotContain("supplierCategory"); } private static void assertPermission(String methodName, String permission) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java new file mode 100644 index 000000000..a6e9f0fe6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.firstleg.dto.FbaPricingDtos; +import com.baiyue.firstleg.dto.InboundFeeDetailDtos; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.lang.reflect.Field; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundFeeOpenApiContractTest { + @Test + void documentsEveryInboundFeeControllerPathAndReadOnlySourceContract() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = map(new Yaml().load(source)); + Map paths = map(document.get("paths")); + assertThat(paths).containsKeys( + "/api/platform/v1/inbounds/{inboundId}/fees/calculate", + "/api/platform/v1/inbounds/{inboundId}/fees/confirm", + "/api/platform/v1/inbounds/{inboundId}/fees/send", + "/api/platform/v1/inbounds/{inboundId}/fees/latest", + "/api/platform/v1/inbounds/{inboundId}/fees/published", + "/api/platform/v1/inbounds/{inboundId}/fee-details", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void"); + assertThat(source).contains("includeHistory", "FBA_PRICING", "MANUAL", "只读派生", + "Finance Outbox", "CALCULATED", "CONFIRMED", "SENT", "SUPERSEDED"); + } + + @Test + void responseAndRequestSchemasTrackActualDtoAndEntityFields() throws Exception { + Map document = map(new Yaml().load(new ClassPathResource( + "openapi/fba-pricing-v1.yaml").getContentAsString(StandardCharsets.UTF_8))); + Map schemas = map(map(document.get("components")).get("schemas")); + + assertThat(properties(schemas, "FeeSnapshotResponse").keySet()) + .containsExactlyInAnyOrder(recordFields(FbaPricingDtos.FeeSnapshotResponse.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailSaveRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.SaveRequest.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailVersionRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.VersionRequest.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailRejectRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.RejectRequest.class).toArray(String[]::new)); + + Set entityFields = new LinkedHashSet<>(); + for (Class type = InboundFeeDetailEntity.class; type != Object.class; type = type.getSuperclass()) { + Arrays.stream(type.getDeclaredFields()).map(Field::getName).forEach(entityFields::add); + } + assertThat(properties(schemas, "InboundFeeDetailResource").keySet()) + .containsExactlyInAnyOrder(entityFields.toArray(String[]::new)); + } + + private static Set recordFields(Class type) { + Set fields = new LinkedHashSet<>(); + Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).forEach(fields::add); + return fields; + } + + private static Map properties(Map schemas, String schema) { + return map(map(schemas.get(schema)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java new file mode 100644 index 000000000..ce62549c7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -0,0 +1,63 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundMeasurementEvidenceOpenApiContractTest { + + @Test + void measurementDetailSeparatesDimensionAndWeightEvidenceByStableRecord() throws Exception { + String source = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = stringMap(new Yaml().load(source)); + Map paths = stringMap(document.get("paths")); + Map detailPath = stringMap(paths.get( + "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement")); + assertThat(detailPath).containsKey("get"); + + Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); + Map detailProperties = properties(schemas, "InboundMeasurementDetail"); + assertThat(detailProperties).containsKeys( + "actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults"); + assertThat(stringMap(detailProperties.get("items"))) + .containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据"); + assertThat(stringMap(detailProperties.get("weightResults"))) + .containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实"); + assertThat(detailProperties).doesNotContainKey("images"); + Map draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest"); + assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items") + .doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm"); + assertThat(paths).containsKey( + "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"); + assertThat(stringMap(stringMap(draftProperties.get("items")).get("items"))) + .containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest"); + assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( + "inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm", + "widthCm", "heightCm", "intelligentImageFileId", "remark"); + + Map itemProperties = properties(schemas, "InboundMeasurementItem"); + assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); + Map weightProperties = properties(schemas, "InboundWeightResult"); + assertThat(weightProperties).containsKeys( + "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); + assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString()) + .contains("CONFIRMED", "grossWeightKg", "含托盘毛重"); + assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString()) + .contains("CONFIRMED", "netWeightKg", "审核与计费以此为准"); + } + + private static Map properties(Map schemas, String schemaName) { + return stringMap(stringMap(schemas.get(schemaName)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map stringMap(Object value) { + return (Map) value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index c563596f5..9e0ac4d84 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -1,10 +1,9 @@ package com.baiyue.firstleg.controller; -import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.dto.MeasurementReviewRequest; -import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.InitialInboundPricingService; import com.baiyue.firstleg.service.MeasurementReviewService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,14 +23,10 @@ import static org.mockito.Mockito.when; @DisplayName("入库测量审核控制器契约") class InboundMeasurementReviewContractTest { @Test - @DisplayName("审核、放行和历史接口声明独立按钮权限") + @DisplayName("审核和历史接口声明独立按钮权限") void shouldDeclareButtonPermissions() throws Exception { assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')", AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); - assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')", - AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); - assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')", - AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class); assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')", AuthenticatedUser.class, long.class, long.class, long.class, long.class); } @@ -43,53 +38,42 @@ class InboundMeasurementReviewContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") - .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") - .contains("firstleg:inbound:exception-release") .contains("Idempotency-Key") - .contains("审核事务提交后服务端自动执行计费") - .contains("接口不会伪造 WAIT_SPLIT") - .contains("成功 data 为 FeeSnapshotResponse") - .contains("nextStatus=WAIT_SPLIT") + .contains("初算异常只返回非阻断提示") + .contains("data.processStatus 固定为 WAIT_SPLIT") + .contains("feeStatus=UNPRICED 且主流程仍成功") .contains("name: inboundId") .contains("schema: {type: string, pattern:") + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:") .doesNotContain("待客户确认"); } @Test - @DisplayName("审核事务完成后才启动自动编排并返回真实费用快照") - void shouldCompleteApprovedReviewAfterReviewTransactionReturns() { + @DisplayName("审核先提交待分柜,再执行不阻塞的初算") + void shouldApproveBeforeBestEffortInitialPricing() { MeasurementReviewService review = mock(MeasurementReviewService.class); - AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class); + InitialInboundPricingService initialPricing = mock(InitialInboundPricingService.class); PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController(); ReflectionTestUtils.setField(controller, "service", review); - ReflectionTestUtils.setField(controller, "automaticCompletion", completion); + ReflectionTestUtils.setField(controller, "initialPricing", initialPricing); AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null, "客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review")); MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过"); - var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class); - when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee); + var result = new MeasurementApprovalResponse(88L, 30L, "WAIT_SPLIT", "UNPRICED", + false, "待人工维护费用", null); + when(initialPricing.tryCalculate(10001L, 9L, 88L, 30L, 5)).thenReturn(result); var first = controller.approve(user, 10001L, 88L, "approve-1", request); var replay = controller.approve(user, 10001L, 88L, "approve-1", request); - assertThat(first.data()).isSameAs(fee); - assertThat(replay.data()).isSameAs(fee); - var ordered = inOrder(review, completion); + assertThat(first.data()).isSameAs(result); + assertThat(replay.data()).isSameAs(result); + var ordered = inOrder(review, initialPricing); ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); - ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); + ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5); ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); - ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); - } - - @Test - void shouldAuditAutomaticInboundCompletion() throws Exception { - Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved", - long.class, long.class, long.class, String.class, long.class, int.class); - BusinessAudit audit = method.getAnnotation(BusinessAudit.class); - assertThat(audit.event()).isEqualTo("INBOUND_AUTO_COMPLETE"); - assertThat(audit.resourceType()).isEqualTo("INBOUND"); - assertThat(audit.resourceId()).isEqualTo("#inboundId"); - assertThat(audit.captureBefore()).isFalse(); + ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5); } private void assertAuthority(String name, String expression, Class... types) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java index 9337857ce..6307b20a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java @@ -51,6 +51,11 @@ class InboundOrderControllerContractTest { .contains("required: false") .contains("InboundOrderSummary:") .contains("required: [id, tenantId]") + .contains("actualNetWeightKg: {type: number, nullable: true") + .contains("只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退") + .contains("只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退") + .contains("chargeableVolumeCbm: {type: number, nullable: true}") + .contains("measureCompletedAt: {type: string, format: date-time, nullable: true}") .contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]") .contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:") .contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, WAIT_SPLIT]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java index dbb07bbb5..6241e98b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java @@ -3,8 +3,11 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; import org.springframework.test.util.ReflectionTestUtils; +import java.nio.charset.StandardCharsets; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; @@ -16,19 +19,19 @@ class InternalFinanceFeeSourceControllerTest { @Test void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() { - assertThatThrownBy(() -> controller.inbound(null, 21L, 31L)) + assertThatThrownBy(() -> controller.inbound(null, 21L, 31L, 2)) .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); - assertThatThrownBy(() -> controller.container("wrong", 41L, 51L)) + assertThatThrownBy(() -> controller.container("wrong", 41L, 51L, 2)) .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); } @Test void routesAuthorizedSnapshotLookup() { - controller.inbound("finance-token", 21L, 31L); - controller.container("finance-token", 41L, 51L); + controller.inbound("finance-token", 21L, 31L, 2); + controller.container("finance-token", 41L, 51L, 3); - verify(service).inbound(21L, 31L); - verify(service).container(41L, 51L); + verify(service).inbound(21L, 31L, 2); + verify(service).container(41L, 51L, 3); } @Test @@ -40,6 +43,18 @@ class InternalFinanceFeeSourceControllerTest { assertThat(methodPath("container")).isEqualTo("/containers/{containerId}/fees/{feeId}/source-snapshot"); } + @Test + void openApiRequiresExactSourceVersionAndDocumentsSupplierConstraint() throws Exception { + String source = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(source) + .contains("/internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:") + .contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:") + .contains("{name: sourceVersion, in: query, required: true") + .contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true") + .contains("为空时仅要求供应商ACTIVE"); + } + private InternalFinanceFeeSourceController controller() { InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service); ReflectionTestUtils.setField(value, "expectedToken", "finance-token"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 2522afe1a..f84cebd7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.service.InboundOrderCreationService; +import com.baiyue.firstleg.service.InboundCartonLabelService; import com.baiyue.firstleg.service.UnifiedInboundQueryService; import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.common.core.api.PageResult; @@ -27,6 +28,8 @@ class InternalInboundOrderReferenceControllerTest { @MockBean private InboundOrderCreationService service; @MockBean + private InboundCartonLabelService cartonLabels; + @MockBean private UnifiedInboundQueryService unifiedQueryService; @Resource private InternalInboundOrderController controller; @@ -79,6 +82,7 @@ class InternalInboundOrderReferenceControllerTest { assertThat(controller.createFromApprovedOrder("internal-token", command).data()).isEqualTo(88L); verify(service).createFromApprovedOrder(command); + verify(cartonLabels).getOrCreate(10001L, 88L); } private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 4ac94979b..d228c6137 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.LoadingListService; @@ -13,6 +14,8 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -32,6 +35,8 @@ class LoadingListControllerContractTest { authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, long.class, long.class, String.class, TransportUpdateRequest.class); + authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class, + long.class, String.class, ConfirmPreloadRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); @@ -48,6 +53,8 @@ class LoadingListControllerContractTest { void writesAudited() throws Exception { audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class, String.class, TransportUpdateRequest.class); + audit("confirmPreload", "PRELOAD_PLAN_CONFIRM", long.class, long.class, String.class, + String.class, ConfirmPreloadRequest.class); } @Test @@ -72,6 +79,16 @@ class LoadingListControllerContractTest { .contains("beforeSnapshot") .contains("containerNo") .contains("voyageNo") + .contains("已推送且仍为 EDITING") + .contains("不能清空已创建 PDA 任务的柜号") + .contains("/loading-plans/{planId}/confirm") + .contains("confirmationStatus") + .contains("canConfirm") + .contains("blockingReasons") + .contains("oceanBookingStatus") + .contains("pdaTaskId") + .contains("cabinetNo") + .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") .doesNotContain("/items/{itemId}"); @@ -89,6 +106,18 @@ class LoadingListControllerContractTest { assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); } + @Test + void multipartScalarFieldsUseRequestParameters() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration", + AuthenticatedUser.class, long.class, long.class, String.class, + MultipartFile.class, int.class, String.class); + assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -100,6 +129,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-plans", "/api/platform/v1/first-leg/loading-plans/{planId}", "/api/platform/v1/first-leg/loading-plans/{planId}/transport", + "/api/platform/v1/first-leg/loading-plans/{planId}/confirm", "/api/platform/v1/first-leg/loading-lists", "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", @@ -111,7 +141,9 @@ class LoadingListControllerContractTest { assertThat(schemas).containsKeys( "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", - "ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope", + "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", + "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", + "BusinessDocumentEnvelope", "DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument"); assertThat(properties(schemas, "DocumentUploadRequest")) .containsOnlyKeys("file", "expectedVersion", "operationReason"); @@ -120,7 +152,9 @@ class LoadingListControllerContractTest { assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages"); assertThat(properties(schemas, "PreloadSummary")) - .containsKeys("planId", "containerBookingId", "customers", "skuCount"); + .containsKeys("planId", "containerBookingId", "containerNo", "cabinetNo", + "customers", "skuCount", "readiness") + .doesNotContainKey("internalContainerNo"); assertThat(properties(schemas, "ConfirmedSummary")) .containsKeys("planId", "containerBookingId", "loadingListNo"); assertThat(properties(schemas, "LoadingItem")).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 58af100bc..73c577614 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -30,6 +30,33 @@ class PdaConvergenceOpenApiContractTest { .contains("InventorySnapshot") .contains("changing it never resynchronizes the task or item versions") .contains("expectedTaskVersion") + .contains("advances every associated inbound from WAIT_SPLIT to LOADING with optimistic locking") + .contains("workflowStatus may later become AWAITING_REVIEW") + .contains("without using containerNo as an association key") + .contains("evidence identity is taskId+inboundId+loadingPlanId+containerBookingId") + .contains("Current fl_inbound_order.version for inboundId") + .contains("Compatibility alias of inboundVersion from the same fl_inbound_order row") + .contains("sourceType", "recordStatus", "mergedIntoContainerBookingId") + .contains("Task detail is served only from the ACTIVE container booking") + .contains("Enforced by the server-side pending-task query") + .contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW") .contains("CompleteLoadingRequest"); + + String taskLookup = yaml.substring( + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks:"), + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/pending:")); + String batchRequest = yaml.substring( + yaml.indexOf(" BatchRequest:"), + yaml.indexOf(" BatchItemRequest:")); + assertThat(taskLookup) + .contains("name: cabinetNo", "name: loadingPlanId") + .doesNotContain("name: containerNo"); + String taskDetail = yaml.substring( + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}:"), + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:")); + assertThat(taskDetail).contains("#/components/schemas/Task", "ACTIVE container lifecycle fields"); + assertThat(batchRequest) + .contains("loadingPlanId:", "containerBookingId:") + .doesNotContain("containerNo:"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java index 04835ee63..7aa0f675e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java @@ -51,12 +51,12 @@ class PlatformCartonScanControllerTest { .thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", "FBA-SCAN-001", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), - new BigDecimal("12.5"), 99001L, "SIM-01", + 99001L, "SIM-01", LocalDateTime.parse("2026-08-07T10:30:00"), true)); MockPart data = new MockPart("data", "data", (""" {"boxCode":"RK-001#5-1","scannedShipmentId":"FBA-SCAN-001", "length":60.1,"width":40.2,"height":30.3, - "weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"} + "machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"} """).getBytes(java.nio.charset.StandardCharsets.UTF_8)); data.getHeaders().setContentType(MediaType.APPLICATION_JSON); MockMultipartFile image = new MockMultipartFile( @@ -91,11 +91,11 @@ class PlatformCartonScanControllerTest { when(cartonMeasurements.upsertManualForInbound(eq(10001L), eq(90001L), eq(70001L), any())) .thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", null, 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), - new BigDecimal("12.5"), 99001L, "MANUAL-ADMIN", + 99001L, "MANUAL-ADMIN", LocalDateTime.parse("2026-08-12T14:00:00"), true)); MockPart data = new MockPart("data", "data", (""" {"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3, - "weight":12.5,"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"} + "machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"} """).getBytes(java.nio.charset.StandardCharsets.UTF_8)); data.getHeaders().setContentType(MediaType.APPLICATION_JSON); MockMultipartFile image = new MockMultipartFile( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 84a4d6774..3299c4100 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -2,21 +2,46 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("平台入库测量接口权限契约") class PlatformInboundMeasurementControllerContractTest { + @Test + void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() { + assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents()) + .map(RecordComponent::getName)).containsExactly( + "measurementId", "version", "actualGoodsName", "items"); + } + + @Test + void draftRequestRejectsRemovedDeclarationFieldsAtDeserialization() { + String json = """ + {"version":0,"originalGrossWeightKg":2000,"actualGoodsName":"货物","items":[]} + """; + assertThatThrownBy(() -> new ObjectMapper().readValue( + json, InboundMeasurementRequests.SaveDraftRequest.class)) + .hasMessageContaining("originalGrossWeightKg"); + } + @Test void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", AuthenticatedUser.class, long.class, long.class); + assertAuthority("weightPalletOptions", + "hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')", + AuthenticatedUser.class, long.class, long.class); assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SaveDraftRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index a173b51db..553b49f95 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformLoadingPdaControllerContractTest { @Test void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { - authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, String.class, Long.class, long.class); + authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, Long.class, long.class); authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class); authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 8c46f1a37..10bdbfc48 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -23,8 +23,45 @@ class PlatformSmartLoadingControllerContractTest { authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class); authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); + authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class); authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); } + @Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception { + String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:") + .contains("不要求真实柜号") + .contains("不创建 PDA 作业任务") + .contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务") + .contains("领取后才进入 LOADING") + .doesNotContain("已关联 CONFIRMED/RELEASED 订舱") + .doesNotContain("真实柜号必须已录入"); + } + @Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception { + String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:") + .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:") + .contains("SmartLoadingAssignRequest:") + .contains("SmartLoadingAdjustRequest:") + .contains("SmartLoadingDeleteRequest:") + .contains("SmartLoadingWarehouseRemarkRequest:") + .contains("/items/{itemId}/warehouse-remark:") + .contains("需要 platform:firstleg:preload:edit 权限") + .contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000") + .contains("成功响应中的 version 是递增后的 planVersion") + .contains("items[].version 是递增后的 itemVersion") + .contains("数量调整或动态增量不会覆盖已有 warehouseRemark") + .contains("canAssign: {type: boolean}") + .contains("canEdit: {type: boolean}") + .contains("pickedPackageCount: {type: integer, minimum: 0}") + .contains("minPackageCount: {type: integer, minimum: 1}") + .contains("canAdjust: {type: boolean}") + .contains("canRemove: {type: boolean}") + .contains("原 inventorySnapshotToken 随即失效") + .contains("PDA task 行锁 -> plan 乐观锁") + .contains("回到 IN_PROGRESS") + .contains("COMPLETED 永久拒绝增量"); + } private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index 1b68274c3..cccb0a153 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; @DisplayName("船期价格表公共契约") class PriceBookContractTest { @@ -23,6 +24,24 @@ class PriceBookContractTest { @DisplayName("固定十五个渠道且海派使用区域重量模型") void keepsStableChannelCatalog() { assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15); + assertThat(PriceBookChannelCatalog.CHANNELS) + .extracting("channelCode", "inboundCodePrefix", "overseasInboundCodePrefix") + .containsExactly( + tuple("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"), + tuple("MATSON_MAX_TRUCK", "BYHK", "BYHWK"), + tuple("EXX_TRUCK", "EXXHK", "EXXHWK"), + tuple("HEDE_TRUCK", "HDHK", "HDHWK"), + tuple("EXX_RAIL_NY", "NYEXX", null), + tuple("OA_LA_TRUCK", "PCHK", "PCHWK"), + tuple("OA_LA_FULL_TRUCK", "PCKK", null), + tuple("OA_CHICAGO_RAIL", "CPHT", null), + tuple("OA_HOUSTON_TRUCK", "HPHK", null), + tuple("OA_NEW_YORK_TRUCK", "NPHK", "NPHWK"), + tuple("OA_SAVANNAH_TRUCK", "SPHK", null), + tuple("MATSON_CLX_PARCEL", "ZBYHP", null), + tuple("MATSON_MAX_PARCEL", "BYHP", null), + tuple("HEDE_PARCEL", "HDHP", null), + tuple("OA_PARCEL", "PCHP", null)); assertThat(PriceBookChannelCatalog.CHANNELS.stream() .map(PriceBookChannelCatalog.ChannelDefinition::channelCode)) .containsExactly( @@ -96,5 +115,12 @@ class PriceBookContractTest { .contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source") .contains("确认并立即生效") .contains("SUPERSEDED"); + + String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(firstlegOpenapi) + .contains("/internal/firstleg/v1/pricing/channels/validate:") + .contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]") + .contains("LEGACY_MANUAL 模式仅校验渠道可用性"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 1b2fdc53a..75ce6d02c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestHeader; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.util.Map; @@ -17,8 +19,19 @@ import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} - @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} + @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","BILL_OF_LADING","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} + @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { + String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); + Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); + Path unifiedPath=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql"); + String unifiedSql=Files.readString(unifiedPath,StandardCharsets.UTF_8); + for(String sql:new String[]{moduleSql,unifiedSql}) { + assertThat(sql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'"); + } + } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} + @Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");} + @Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index 363795dcb..d18b7a6e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -40,8 +40,9 @@ class UnifiedInboundOpenApiContractTest { "businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", - "inboundedAt", "expressCompanyCode", "expressCompanyName", "loadingStatus", "containerNo", - "cabinetNo", "sailingAt", "portArrivalAt", "createdAt", "updatedAt"); + "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", + "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", + "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 179db670f..33463262f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -30,7 +30,7 @@ class CustomerTransitStatisticsContractTest { .findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value(); var statement = String.join("\n", sql); assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL", - "status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id", + "status <> 'CANCELLED'", "DISTINCT x.customer_id, x.container_booking_id", "b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS", "x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0", "LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java index 5e090bdca..3bd9a2a96 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.customs.client.CustomsFileClient; import com.baiyue.firstleg.customs.dto.CustomsDtos.*; import com.baiyue.firstleg.customs.entity.*; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +25,7 @@ import static org.mockito.Mockito.*; class CustomsServiceTest { @Mock CustomsRepository repository; @Mock CustomsFileClient files; + @Mock WarehouseSupplierSnapshotService suppliers; @InjectMocks CustomsService service; @BeforeEach @@ -31,6 +33,8 @@ class CustomsServiceTest { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "internalToken", "token"); + when(suppliers.customs(anyLong(), anyString())) + .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE")); when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1); } @@ -84,9 +88,11 @@ class CustomsServiceTest { when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1); CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1", - new SubmitBatchRequest("线下报关代理", "EXT-1", "提交", 4)); + new SubmitBatchRequest(88L, "线下报关代理", "EXT-1", "提交", 4)); assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE"); + assertThat(result.getCustomsAgentId()).isEqualTo(88L); + assertThat(result.getCustomsAgentName()).isEqualTo("线下报关代理"); verify(repository).freeze(51L); verify(repository).insert(any(CustomsSubmissionBatchEntity.class)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java new file mode 100644 index 000000000..a4848da93 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.domain; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerSpecificationCatalogTest { + @Test + void exposesCanonicalSpecificationsAndRejectsAliases() { + var specification = ContainerSpecificationCatalog.requireMatch( + "40HC", new BigDecimal("76.000"), new BigDecimal("28000.000")); + + assertThat(specification.containerType()).isEqualTo("40HC"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ")) + .isInstanceOf(BizException.class).hasMessageContaining("固定规格"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( + "40HC", new BigDecimal("68"), new BigDecimal("28000"))) + .isInstanceOf(BizException.class).hasMessageContaining("容量和限重"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java new file mode 100644 index 000000000..5937a6f8c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class CreateWeightResultRequestValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void requiresAValidFileAndPalletBinding() { + var valid = request(120L, 501L); + var missingPallet = request(120L, null); + var invalidPallet = request(120L, 0L); + + assertThat(validator.validate(valid)).isEmpty(); + assertThat(validator.validate(missingPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + assertThat(validator.validate(invalidPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + } + + private InboundMeasurementRequests.CreateWeightResultRequest request(Long fileId, Long palletId) { + return new InboundMeasurementRequests.CreateWeightResultRequest( + fileId, palletId, "FILE_WEIGHT_OCR", "30.000 KG", new BigDecimal("30.000"), + "KG", new BigDecimal("0.99000")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java new file mode 100644 index 000000000..b4d42db61 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.entity.InboundOrderEntity; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundOrderWeightSemanticsTest { + @Test + void summaryKeepsGrossAndNetWeightAsIndependentDatabaseFacts() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setActualGrossWeightKg(new BigDecimal("1787.500")); + inbound.setActualNetWeightKg(new BigDecimal("1747.500")); + + InboundOrderSummary result = InboundOrderSummary.from( + inbound, (CurrentContainerTransport) null, null, null); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(result.actualNetWeightKg()).isEqualByComparingTo("1747.500"); + } + + @Test + void summaryDoesNotFallbackBetweenGrossAndNetWeight() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setActualGrossWeightKg(new BigDecimal("1787.500")); + + InboundOrderSummary result = InboundOrderSummary.from( + inbound, (CurrentContainerTransport) null, null, null); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(result.actualNetWeightKg()).isNull(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index f4fbaf46d..1b786f3de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot; import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope; +import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +44,101 @@ class LoadingModelBridgeServiceTest { verify(repository).touchLoadingPlan(40, 3, 7, "admin"); } + @Test + void bindsEveryAllocatedInboundBeforeLoadingStarts() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(8); + when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1); + Link linked = link("PENDING", 3, "EDITING", 4, 8); + when(repository.byInbound(10, 20)).thenReturn(List.of(linked)); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + List result = service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"); + + assertThat(result).containsExactly(linked); + verify(repository).insert(any()); + verify(repository).touchPreload(30, 10, 20, 2, 7, "admin"); + verify(repository).touchLoadingPlan(40, 3, 7, "admin"); + } + + @Test + void editingReplayWithEveryPairExistingIsReadOnly() { + var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9); + when(repository.loadingPlan(40)).thenReturn(plan); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + Link first = link("PENDING", 3, "EDITING", 9, 8); + when(repository.byLoadingPlan(40)).thenReturn(List.of(first)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(first); + + verify(repository, never()).preload(anyLong(), anyLong()); + verify(repository, never()).insert(any()); + verify(repository, never()).touchPreload(anyLong(), anyLong(), anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString()); + } + + @Test + void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + when(repository.byPair(11, 21, 40)).thenReturn(null); + when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5)); + when(repository.loadingPlan(40)).thenReturn( + new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3), + new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedPackages(11, 21, 40)).thenReturn(4); + when(repository.touchPreload(31, 11, 21, 5, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1); + Link linked = link("PENDING", 6, "EDITING", 4, 4); + when(repository.byInbound(11, 21)).thenReturn(List.of(linked)); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(linked); + + verify(repository, never()).preload(10, 20); + verify(repository, times(2)).preload(11, 21); + verify(repository, times(1)).insert(any()); + verify(repository, times(1)).touchPreload(31, 11, 21, 5, 7, "admin"); + verify(repository, times(1)).touchLoadingPlan(40, 3, 7, "admin"); + } + + @Test + void refusesPostHocBindingAfterLoadingStarts() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + + assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .hasMessageContaining("禁止在LOADING后补关联"); + + verify(repository, never()).insert(any()); + } + + @Test + void loadingReplayOnlyReadsAlreadyCompleteLinks() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity(); + when(repository.byPair(10, 20, 40)).thenReturn(existing); + Link linked = link("PENDING", 3, "LOADING", 4, 8); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(linked); + + verify(repository, never()).insert(any()); + verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString()); + } + @Test void secondVersionConflictDoesNotProceedAsSuccess() { when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); @@ -55,6 +152,33 @@ class LoadingModelBridgeServiceTest { verify(repository, never()).byInbound(10, 20); } + @Test + void publicBindSameKeyWithChangedVersionsStillConflicts() { + var prior = new PreloadLoadingPlanLinkEntity(); + prior.setRequestHash("prior-request-hash"); + when(repository.byIdempotency(7, "manual-key")).thenReturn(prior); + + assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "manual-key", + new BindRequest(3, 4))) + .hasMessageContaining("Idempotency-Key已用于不同关联请求"); + + verify(repository, never()).byPair(anyLong(), anyLong(), anyLong()); + verify(repository, never()).insert(any()); + } + + @Test + void confirmationGateRejectsIncompleteAllocatedLinks() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( + 40L, 50L, "C1", "EDITING", 3, LocalDateTime.now())); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byLoadingPlan(40)).thenReturn(List.of( + link("PENDING", 2, "EDITING", 3, 8))); + + assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40)) + .hasMessageContaining("关联不完整"); + } + @Test void terminalGateRequiresEveryLinkedContainerPlanCompleted() { when(repository.byInbound(10, 20)).thenReturn(List.of( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 8ccd887a9..03b0365e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -16,15 +16,19 @@ class LoadingDeliveryOpenApiContractTest { assertThat(yaml) .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:") + .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:") .contains("required: [expectedVersion, evidenceType, fileId]") + .contains("previewUrl:") .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") .contains("同类型可多次追加") .contains("Required when evidenceType is SEAL") .contains("compatibility projection") + .contains("associated inbound process status to be LOADING") + .contains("workflowStatus AWAITING_REVIEW does not bypass this gate") .contains("must exactly match the server-derived container-task aggregate") .contains("cannot complete canonical picking"); assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java index 10e086560..b647e0b0f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java @@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest { "platform:firstleg:pda:work", UpsertEvidenceRequest.class); get("pdaEvidence", "/{inboundId}/loading/evidence/pda", "platform:firstleg:pda:work"); + Method pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent", + AuthenticatedUser.class, long.class, long.class, long.class); + assertThat(pdaContent.getAnnotation(GetMapping.class).value()) + .containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda"); + assertAuthority(pdaContent, "platform:firstleg:pda:work"); Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence", AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class); assertThat(pdaVoid.getAnnotation(PostMapping.class).value()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java index 56687799e..66e311dab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingCompletionGateSqlContractTest { @Test - void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception { + void requiresEveryLinkedContainerToHaveReleasedBookingWithoutTruckPrerequisite() throws Exception { Select select = LoadingCompletionGateMapper.class .getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class); String sql = String.join(" ", select.value()).replaceAll("\\s+", " "); @@ -17,10 +17,8 @@ class LoadingCompletionGateSqlContractTest { .contains("JOIN fl_loading_plan p ON p.id=l.loading_plan_id") .contains("fl_ocean_booking_container_allocation") .contains("b.status='RELEASED'") - .contains("fl_truck_appointment") - .contains("t.status='COMPLETED'") + .doesNotContain("fl_truck_appointment", "truckReady", "t.status='COMPLETED'") .contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}") - .doesNotContain("truckReady, FROM") .doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady") .doesNotContain("<", ">"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 21f02c468..7fdc885e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest { token.set(service, "token"); } + @Test + void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() { + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null); + target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE"); + when(repository.listEvidence(10, 20)).thenReturn(List.of(target)); + when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3})); + + var listed = service.evidence(10, 20); + var content = service.evidenceContent(10, 20, 99); + + assertThat(listed.evidences()).singleElement().satisfies(item -> + assertThat(item.previewUrl()).isEqualTo( + "/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10")); + assertThat(content.getBody()).containsExactly(1, 2, 3); + verify(files).download("token", 10, 20, 11); + } + @Test void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); @@ -79,10 +98,48 @@ class LoadingDeliveryServiceTest { LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); } + @Test + void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() { + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + when(repository.listEvidence(10, 20)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), + evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), + evidence("SEAL", 5L, "LOCK-001"))); + when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { + long fileId = invocation.getArgument(3); + String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; + return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( + fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); + }); + + service.requirePdaCompletionReady(10, 20); + + verify(loadingModelBridge).requireStarted(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); + verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong()); + verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); + verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); + } + + @Test + void pdaCompletionPreflightReportsBookingBlockerBeforeEvidenceOrWrites() { + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, + "LOADED门禁未通过:订舱尚未完成(必须为 RELEASED)")) + .when(completionGate).requireReleasedBooking(10, 20); + + assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20)) + .hasMessageContaining("订舱尚未完成"); + + verify(repository, never()).listEvidence(anyLong(), anyLong()); + verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); + } + @Test void completesLoadedFulfillmentSliceBeforeAggregatingRoot() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); @@ -113,7 +170,7 @@ class LoadingDeliveryServiceTest { assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(repository).completeLoadedChild(10, 20, 7, 3); verify(repository).aggregateRootLoaded(10, 20, 7); verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> @@ -121,6 +178,62 @@ class LoadingDeliveryServiceTest { verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); } + @Test + void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, null, 20L, + "LOADING", 7, "LOADING", 7); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-full", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(repository).findInboundForUpdate(10, 20); + verify(repository).completeLoadedChild(10, 20, 7, 7); + verify(repository, never()).completeLoadedChild(10, 20, 7, 6); + } + + @Test + void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, 21L, 20L, + "LOADING", 7, "LOADING", 2); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 21)).thenReturn(new Snapshot(21L, 10L, "LOADING", 2)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 2)).thenReturn(1); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-partial", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(repository).findInboundForUpdate(10, 21); + verify(repository).completeLoadedChild(10, 20, 7, 2); + } + + @Test + void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, null, 20L, + "LOADING", 7, "LOADING", 7); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(0); + + assertThatThrownBy(() -> service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-concurrent", "完成")) + .hasMessageContaining("数据已被其他人修改"); + + verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); + verify(repository, never()).completeOperation(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + } + @Test void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() { AtomicInteger claims = new AtomicInteger(); @@ -172,7 +285,7 @@ class LoadingDeliveryServiceTest { verify(repository).completeLoadedChild(10, 20, 7, 5); verify(repository).aggregateRootLoaded(10, 10, 7); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); } @@ -297,10 +410,33 @@ class LoadingDeliveryServiceTest { private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) { LoadingEvidenceEntity value = new LoadingEvidenceEntity(); + value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L); value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo); return value; } + private void readyForPdaFinalize() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(true); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); + when(repository.listEvidence(10, 20)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), + evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), + evidence("SEAL", 5L, "LOCK-001"))); + when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { + long fileId = invocation.getArgument(3); + String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; + return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( + fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); + }); + } + private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) { LoadingOperationEntity value = new LoadingOperationEntity(); value.setRequestHash(requestHash); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 030fb265f..df43c1be5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -18,23 +20,17 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), - Map.entry("submit", "platform:firstleg:ocean-booking:submit"), - Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"), - Map.entry("fail", "platform:firstleg:ocean-booking:confirm"), - Map.entry("release", "platform:firstleg:ocean-booking:release"), - Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"), - Map.entry("requestChange", "platform:firstleg:ocean-booking:change"), - Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"), - Map.entry("failChange", "platform:firstleg:ocean-booking:change"), - Map.entry("allocate", "platform:firstleg:ocean-booking:allocate")); + Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"), + Map.entry("release", "platform:firstleg:ocean-booking:release")); permissions.forEach((name, permission) -> { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -49,6 +45,18 @@ class PlatformOceanBookingControllerContractTest { .allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue(); } + @Test + void openApiFreezesServerResolvedChannelContract() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", + "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") + .contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT") + .contains("Validate and permanently release a DRAFT") + .contains("containerRequirements:", "ContainerRequirementRequest:") + .contains("never requires or creates a real container number") + .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 3fd18bf26..050195e76 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -16,10 +16,12 @@ class OceanBookingMapperContractTest { com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("customer_id=#{query.customerId}") - .contains("status=#{query.status}") - .contains("booking_no LIKE") - .contains("ORDER BY updated_at DESC,id DESC"); + assertThat(sql).contains("booking.customer_id=#{query.customerId}") + .contains("booking.status=#{query.status}") + .contains("booking.booking_no LIKE") + .contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'") + .contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count") + .contains("ORDER BY booking.updated_at DESC,booking.id DESC"); } @Test @@ -30,10 +32,8 @@ class OceanBookingMapperContractTest { .flatMap(annotation -> Arrays.stream(annotation.value())) .reduce("", (left, right) -> left + '\n' + right); assertThat(sql) - .contains("status='DRAFT' AND version=#{expectedVersion}") .contains("status=#{fromStatus} AND version=#{expectedVersion}") - .contains("status='CONFIRMED' AND version=#{expectedVersion}") - .contains("status='CHANGE_PENDING' AND version=#{expectedVersion}") - .contains("status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')"); + .contains("status='DRAFT' AND version=#{version}") + .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index f106d67b9..9f2df34e0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -18,22 +18,38 @@ class OceanBookingSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") + .contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") + .contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") + .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") + .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") + .contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") + .contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE") .doesNotContain("ALTER TABLE fl_container_booking"); } @Test void enforcesStateIdempotencyAndAuditContracts() throws Exception { assertThat(sql()) - .contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')") - .contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')") - .contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')") - .contains("OLD.status IN ('RELEASED','CANCELLED')") + .contains("OLD.status='DRAFT' AND NEW.status='RELEASED'") + .contains("OLD.status = 'RELEASED'") .contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)") .contains("request_hash CHAR(64) NOT NULL") + .contains("<> 'RELEASED' THEN") .contains("订舱状态历史不可修改") - .contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)"); + .contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)") + .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED"); + } + + @Test + void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception { + assertThat(sql()) + .contains("DROP CHECK chk_ocean_booking_status") + .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") + .contains("DROP COLUMN failure_reason") + .doesNotContain("UPDATE fl_ocean_booking SET status="); } @Test @@ -42,13 +58,11 @@ class OceanBookingSqlContractTest { .contains("platform:firstleg:ocean-booking:list") .contains("platform:firstleg:ocean-booking:create") .contains("platform:firstleg:ocean-booking:edit") - .contains("platform:firstleg:ocean-booking:submit") - .contains("platform:firstleg:ocean-booking:confirm") + .contains("platform:firstleg:ocean-booking:delete") .contains("platform:firstleg:ocean-booking:release") - .contains("platform:firstleg:ocean-booking:cancel") - .contains("platform:firstleg:ocean-booking:change") .contains("platform:firstleg:ocean-booking:allocate") - .doesNotContain("platform:firstleg:ocean-booking:view"); + .doesNotContain("platform:firstleg:ocean-booking:view", + "platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change"); } private static String sql() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d9cd3c9b0..06019bc33 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -1,27 +1,30 @@ package com.baiyue.firstleg.oceanbooking.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; -import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; -import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; -import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; -import com.baiyue.firstleg.entity.ContainerBookingEntity; -import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; -import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -30,137 +33,227 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; + @Mock private PriceBookQueryRepository priceBooks; + @Mock private WarehouseSupplierSnapshotService suppliers; + @Mock private WarehouseMasterDataClient warehouses; + @Mock private FirstlegDocumentNumberGenerator documentNumbers; @InjectMocks private OceanBookingService service; + private ObjectMapper objectMapper; @BeforeEach void setUp() throws Exception { MockitoAnnotations.openMocks(this); + objectMapper = new ObjectMapper().findAndRegisterModules(); + lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001"); var field = OceanBookingService.class.getDeclaredField("objectMapper"); - field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules()); + field.setAccessible(true); + field.set(service, objectMapper); when(repository.parties(anyLong())).thenReturn(List.of()); when(repository.requirements(anyLong())).thenReturn(List.of()); - when(repository.allocations(anyLong())).thenReturn(List.of()); + when(repository.containers(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1); + when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1); } @Test - void advancesDraftToSubmittedWithVersionAndHistory() { - when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3)); - var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER"); - var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE"); - var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY"); - when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify)); - when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity())); - when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1); + void savesBlankDraftWithoutCreatingContainersOrAllocations() { + AtomicReference inserted = stubCreatedDraft(); + Detail result = service.create(9L, "admin", "draft-empty", + new CreateRequest(0, partialDraft(null))); - Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交")); - - assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED"); - ArgumentCaptor history = - ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class); - verify(repository).insert(history.capture()); - assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT"); - assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED"); - assertThat(history.getValue().getAction()).isEqualTo("SUBMIT"); + assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT"); + assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001"); + assertThat(inserted.get().getCustomerId()).isNull(); + verify(repository).replaceRequirements(inserted.get().getId(), List.of()); + verify(repository, never()).insertContainer(any()); + verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class)); + verifyNoInteractions(priceBooks, suppliers, warehouses); } @Test - void rejectsCancellationAfterRelease() { - when(repository.find(1L)).thenReturn(booking("RELEASED", 4)); + void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() { + stubMasterData(); + AtomicReference inserted = stubCreatedDraft(); + Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft())); - assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1", - new ActionRequest(4, "错误"))).isInstanceOf(BizException.class) - .hasMessageContaining("不可逆"); - - verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); + assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT"); + assertThat(inserted.get().getChannelName()).isEqualTo("美西海运"); + assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓"); + assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理"); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture()); + assertThat(captor.getValue()).singleElement().satisfies(value -> { + assertThat(value.getContainerType()).isEqualTo("40HC"); + assertThat(value.getQuantity()).isEqualTo(2); + }); + verify(repository, never()).insertContainer(any()); + verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class)); } @Test - void keepsCurrentVoyagePendingUntilChangeConfirmation() { - OceanBookingEntity current = booking("CONFIRMED", 5); - current.setChannelCode("CHANNEL-A"); - current.setVesselName("OLD"); current.setVoyageNo("V1"); - OceanBookingEntity pending = booking("CHANGE_PENDING", 6); - pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2"); - when(repository.find(1L)).thenReturn(current, pending); - when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1); + void updatesOnlyDraftWithExpectedVersion() { + when(repository.find(1L)).thenReturn(booking("DRAFT", 3), booking("DRAFT", 4)); + when(repository.updateDraft(any(), eq(3), eq(9L), eq("admin"))).thenReturn(1); + Detail result = service.updateDraft(1L, 9L, "admin", "update-1", + new UpdateRequest(3, partialDraft("备注"))); - Detail result = service.requestChange(1L, 9L, "admin", "change-1", - new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0), - LocalDateTime.of(2026, 9, 20, 0, 0), "改船")); - - assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING"); - assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD"); - assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW"); + assertThat(result.oceanBooking().getVersion()).isEqualTo(4); + verify(repository).replaceParties(1L, List.of()); + verify(repository).replaceRequirements(1L, List.of()); } @Test - void replaysCompletedRequestBeforeVersionValidation() throws Exception { - OceanBookingEntity submitted = booking("SUBMITTED", 3); - Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of()); + void rejectsReleaseWhenPersistedAggregateIsIncompleteWithoutWriting() { + when(repository.find(1L)).thenReturn(booking("DRAFT", 3)); + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-missing", + new ReleaseRequest(3, "确认"))) + .isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); + } + + @Test + void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception { + OceanBookingEntity draft = completeBooking("DRAFT", 3); + OceanBookingEntity released = completeBooking("RELEASED", 4); + when(repository.find(1L)).thenReturn(draft, released); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1); + ReleaseRequest request = new ReleaseRequest(3, "确认放舱"); + + Detail first = service.release(1L, 9L, "admin", "release-1", request); + assertThat(first.oceanBooking().getStatus()).isEqualTo("RELEASED"); + OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); - operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS"); - operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay)); - when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false); - when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation); + operation.setRequestHash(hash("RELEASE", 1L, request)); + operation.setStatus("SUCCESS"); + operation.setResultJson(objectMapper.writeValueAsString(first)); + when(repository.claimOperation(1L, 9L, "RELEASE", "release-1", operation.getRequestHash())).thenReturn(false); + when(repository.operation(9L, "RELEASE", "release-1")).thenReturn(operation); - Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交")); - - assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED"); - verify(repository, never()).find(anyLong()); + assertThat(service.release(1L, 9L, "admin", "release-1", request).oceanBooking().getStatus()) + .isEqualTo("RELEASED"); + verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin"); } @Test - void allocatesContainerWithExplicitTimestampAndVersionGuard() { - when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6)); - OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ"); - requirement.setQuantity(1); - ContainerBookingEntity container = new ContainerBookingEntity(); - container.setChannelCode("CHANNEL-A"); - container.setId(12L); container.setContainerType("40HQ"); - when(repository.requirement(1L, 11L)).thenReturn(requirement); - when(repository.containerBooking(12L)).thenReturn(container); - when(repository.touchForAllocation(1L, 5, 9L, "admin")).thenReturn(1); - - Detail result = service.allocate(1L, 9L, "admin", "allocate-1", - new AllocationRequest(11L, 12L, 5)); - - ArgumentCaptor allocation = - ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class); - verify(repository).insert(allocation.capture()); - assertThat(allocation.getValue().getOceanBookingId()).isEqualTo(1L); - assertThat(allocation.getValue().getRequirementId()).isEqualTo(11L); - assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(12L); - assertThat(allocation.getValue().getAllocatedAt()).isNotNull(); - assertThat(result.oceanBooking().getVersion()).isEqualTo(6); + void releasedAggregateRejectsEditDeleteAndRepeatRelease() { + when(repository.find(1L)).thenReturn(booking("RELEASED", 4)); + assertThatThrownBy(() -> service.updateDraft(1L, 9L, "admin", "edit-released", + new UpdateRequest(4, partialDraft(null)))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.deleteDraft(1L, 9L, "admin", "delete-released", + new ActionRequest(4, null))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-again", + new ReleaseRequest(4, null))).isInstanceOf(BizException.class); + verify(repository, never()).deleteDraft(anyLong(), anyInt()); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); } @Test - void rejectsAllocationAfterReleaseBeforeMutation() { - when(repository.find(1L)).thenReturn(booking("RELEASED", 5)); + void deleteDraftKeepsIdempotencyDetachedFromDeletedAggregate() { + when(repository.find(1L)).thenReturn(booking("DRAFT", 2)); + when(repository.deleteDraft(1L, 2)).thenReturn(1); + service.deleteDraft(1L, 9L, "admin", "delete-1", new ActionRequest(2, "作废草稿")); - assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released", - new AllocationRequest(11L, 12L, 5))) - .isInstanceOf(BizException.class) - .hasMessageContaining("当前订舱状态不可分配柜次"); - - verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString()); - verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class)); + verify(repository).claimOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString()); + verify(repository).completeOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString()); } - private String hashForSubmit() throws Exception { - var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); - method.setAccessible(true); - return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交")); + @Test + void supportsOnlyDraftAndReleasedListStatuses() { + service.page(new Query(null, "DRAFT", null, 1, 20)); + service.page(new Query(null, "RELEASED", null, 1, 20)); + assertThatThrownBy(() -> service.page(new Query(null, "CONFIRMED", null, 1, 20))) + .isInstanceOf(BizException.class).hasMessageContaining("状态"); + verify(repository, times(2)).page(any()); + } + + @Test + void releaseRejectsInvalidScheduleWithoutTransition() { + OceanBookingEntity draft = completeBooking("DRAFT", 3); + draft.setEstimatedArrivalAt(draft.getEstimatedSailingAt().minusDays(1)); + when(repository.find(1L)).thenReturn(draft); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-range", + new ReleaseRequest(3, null))).isInstanceOf(BizException.class).hasMessageContaining("不能早于"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); + } + + private AtomicReference stubCreatedDraft() { + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + return inserted; + } + + private void stubMasterData() { + ChannelRow row = new ChannelRow(); + row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运"); + row.setPriceModel("FBA_WAREHOUSE_RATE"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row)); + when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success( + new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( + 701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE")))); + when(suppliers.booking(81L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE")); + } + + private static DraftData partialDraft(String remark) { + return new DraftData(null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, remark, null, null); + } + + private static DraftData completeDraft() { + return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", + 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", + "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), + LocalDateTime.of(2026, 9, 20, 0, 0), null, + List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), + new PartyRequest("CONSIGNEE", "收货人", null, null, null), + new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), + List.of(new ContainerRequirementRequest("40HC", "DRY", 2, + new BigDecimal("76"), new BigDecimal("28000")))); + } + + private static OceanBookingEntity completeBooking(String status, int version) { + OceanBookingEntity value = booking(status, version); + value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); + value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); + value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); + value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); + value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); + value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0)); + return value; } private static OceanBookingEntity booking(String status, int version) { - OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1"); - value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); - value.setChannelCode("CHANNEL-A"); value.setVersion(version); + OceanBookingEntity value = new OceanBookingEntity(); + value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setVersion(version); return value; } + + private static List parties() { + return List.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY").stream().map(type -> { + var value = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); + value.setPartyType(type); value.setPartyName(type); return value; + }).toList(); + } + + private static OceanBookingContainerRequirementEntity requirement() { + var value = new OceanBookingContainerRequirementEntity(); + value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY"); + value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76")); + value.setMaxWeightKg(new BigDecimal("28000")); return value; + } + + private String hash(String action, Long id, Object request) throws Exception { + var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); + method.setAccessible(true); + return (String) method.invoke(service, action, id, request); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 3ba8de3e7..81158d28a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -7,6 +7,14 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class OutboundDispatchReadMapperContractTest { + @Test + void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindings", Long.class, Long.class, Long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + } + @Test void pickTaskQueryOrdersByTheSchemaStartedAtColumn() throws Exception { String sql = OutboundDispatchReadMapper.class.getMethod("pick", long.class) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index d8fe59eee..1988f56f3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -129,7 +129,7 @@ class OutboundDispatchServiceTest { private static OutboundDispatchReadMapper.Binding binding() { return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", - "LOADED", 22L, "C-22", 44L, "COMPLETED"); + "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } private static OutboundDispatchGateContributor passing(String code) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 2e64d996b..d122b27da 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -24,6 +24,10 @@ class ContainerBookingMapperContractTest { assertThat(select) .contains("fl_loading_plan") .contains("p.id AS plan_id") + .contains("p.plan_no AS plan_no") + .contains("fl_container_exception_history") + .contains("destination_clearance_document_status") + .contains("delivery_order_document_status") .contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'") .contains("plan_status") .contains("p.plan_status != 'COMPLETED'") @@ -32,6 +36,31 @@ class ContainerBookingMapperContractTest { .doesNotContain("account_id"); } + @Test + @DisplayName("货柜分页按装柜明细客户归属精确筛选") + void filtersPageByStableCustomerId() throws Exception { + String page = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, + com.baiyue.firstleg.dto.ContainerDtos.Query.class) + .getAnnotation(Select.class).value()); + assertThat(page) + .contains("fl_ocean_booking_container_allocation customer_allocation") + .contains("customer_allocation.container_booking_id = b.id") + .contains("customer_booking.customer_id = #{query.customerId}") + .contains("customer_item.loading_plan_id = p.id") + .contains("customer_item.deleted = 0") + .contains("customer_item.account_id = #{query.customerId}"); + } + + @Test + @DisplayName("首次关联客户隔离查询使用原生不等号") + void customerMismatchQueryUsesNativeSqlOperator() throws Exception { + String sql = ContainerBookingMapper.class + .getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>"); + } + @Test @DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁") void guardsUpdatesInSql() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java new file mode 100644 index 000000000..7d7626628 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerFeeDetailRepositoryTest { + @Test + void adjustmentUpdateOnlyTouchesAmountDateVersionAndReviewProjection() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java"), + StandardCharsets.UTF_8); + String method = source.substring(source.indexOf("public int adjust("), + source.indexOf("public int voidApproved(")); + + assertThat(method).contains("getBusinessDate", "getAmount", "getSourceVersion", + "getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt", + "getReviewReason", "getUpdatedBy", "getUpdatedAt") + .doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot", + "getDirection", "getSupplierId", "getSupplierName", "getSupplierCategory", "getCurrency", + "getRemark"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index 1d92a7963..afecb8b9e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.repository; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -10,6 +12,24 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("loading_plan_id AS loadingPlanId", + "container_booking_id AS containerBookingId", "task_status AS taskStatus", "version") + .doesNotContain("SELECT *"); + } + + @Test void exceptionQueriesSelectEveryExceptionRowConstructorField() throws Exception { + for (String method : java.util.List.of("exceptions", "latestException")) { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod(method, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("SELECT id,action,reason", "operator_id operatorId", + "operator_name operatorName", "occurred_at occurredAt", "request_hash requestHash") + .doesNotContain("SELECT *"); + } + } + @Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception { String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 05c24592f..e1451a307 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -22,6 +22,9 @@ class FeeItemMigrationSqlContractTest { .contains("LEFT JOIN tmp_fee_item_seed matched_seed") .contains("matched_seed.fee_code = NULLIF(TRIM(f.fee_code), '')") .contains("matched_seed.trade_scope = f.trade_scope") + .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") + .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("warehouse_receivable = 1") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") .doesNotContain("OR (SELECT COUNT(*) FROM tmp_fee_item_seed") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java index f74e30658..e2b22d5cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java @@ -9,19 +9,21 @@ import static org.assertj.core.api.Assertions.assertThat; class FinanceFeeSourceMigrationContractTest { @Test - void freshSchemaAndMigrationExposeInboundFeePartnerSnapshotsUsedByAllFeeFlows() throws Exception { + void freshSchemaAndMigrationExposeInboundFeeSupplierSnapshotsUsedByAllFeeFlows() throws Exception { String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql") .getContentAsString(StandardCharsets.UTF_8); String migration = new ClassPathResource("db/finance-fee-source-migration.sql") .getContentAsString(StandardCharsets.UTF_8); assertThat(baseline).contains( - "partner_id BIGINT UNSIGNED NULL", - "partner_name VARCHAR(200) NULL"); + "supplier_id BIGINT UNSIGNED NULL", + "supplier_name VARCHAR(200) NULL", + "supplier_category VARCHAR(16) NULL"); assertThat(migration).contains( - "firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'", - "firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name'", - "Firstleg Finance迁移失败:入库费用往来单位快照字段不完整"); + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'", + "Firstleg Finance迁移失败:入库费用供应商快照字段不完整"); } @Test @@ -32,10 +34,12 @@ class FinanceFeeSourceMigrationContractTest { assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash", "uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by", "idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail", - "settlement_included", "source_version", "partner_id", "partner_name", - "firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE", - "firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'", - "Firstleg Finance迁移失败:入库费用往来单位快照字段不完整", + "source_version", "supplier_id", "supplier_name", "supplier_category", + "CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot", + "uk_container_fee_source_version", + "firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'", + "Firstleg Finance迁移失败:入库费用供应商快照字段不完整", "chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java new file mode 100644 index 000000000..f5b01c360 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeGlobalUniquenessContractTest { + @Test + void inboundSchemaUsesTheSamePlatformWideInboundCodeIndex() throws Exception { + String sql = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("UNIQUE KEY uk_inbound_code (inbound_code)") + .doesNotContain("UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index 8fa57849c..66d5a1993 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; import org.junit.jupiter.api.Test; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.mockito.ArgumentCaptor; @@ -37,6 +38,7 @@ import static org.mockito.Mockito.when; class InboundOrderRepositoryTest { @MockBean private InboundOrderMapper orders; @MockBean private InboundTransportSnapshotMapper transports; + @MockBean private InboundPickupSnapshotMapper pickupSnapshots; @MockBean private InboundPalletAllocationMapper pallets; @MockBean private InboundFileMapper files; @MockBean private InboundInternalNoteMapper notes; @@ -62,7 +64,9 @@ class InboundOrderRepositoryTest { String sql = captor.getValue().getCustomSqlSegment(); assertThat(sql).contains("inbound_code", "order_status_snapshot", "expected_arrival_at", "channel_name", "container_mode", "measure_review_status", "order_id IN", - "fl_inbound_fee", "fl_inbound_transport_snapshot", "sailing_at", "loading_status"); + "fl_inbound_fee", "fl_loading_plan_item", "fl_loading_plan", "fl_container_booking", + "actual_sailing_at", "estimated_sailing_at", "plan_status"); + assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "loading_status"); LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); assertThat(wrapper.getParamNameValuePairs().values()) @@ -162,8 +166,6 @@ class InboundOrderRepositoryTest { assertThat(result.records()).extracting(record -> record.tenantId()) .containsExactly(10001L, 10002L); - assertThat(result.records()).extracting(record -> record.loadingStatus()) - .containsExactly("T1", "T2"); assertThat(result.records()).extracting(record -> record.locationCodes()) .containsExactly("A1", "B1"); ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(Wrapper.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java index 59bd005ff..bc33cd856 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -11,16 +11,28 @@ import static org.assertj.core.api.Assertions.assertThat; class InboundOrderStatusSqlContractTest { @Test - void advancesThroughPricingBeforeOpeningWaitSplit() { + void opensWaitSplitImmediatelyAfterMeasurementApproval() { assertThat(sql("approveMeasurement")) - .contains("process_status = 'WAIT_PRICING'") - .doesNotContain("inbounded_at ="); + .contains("process_status = 'WAIT_SPLIT'") + .contains("inbounded_at = CURRENT_TIMESTAMP(3)") + .doesNotContain("process_status = 'WAIT_PRICING'"); assertThat(sql("completeInbound")) .contains("process_status = 'WAIT_SPLIT'") .contains("inbounded_at = CURRENT_TIMESTAMP(3)") .contains("process_status = 'INBOUNDING'"); } + @Test + void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() { + assertThat(sql("approveMeasurement")) + .contains("FROM fl_inbound_weight_result weight") + .contains("weight.recognition_status = 'CONFIRMED'") + .contains("weight.deleted = 0") + .contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)") + .contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)") + .doesNotContain("item.total_weight_kg"); + } + private static String sql(String methodName) { Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java index 9b27cc89e..b04320e20 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -9,10 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import java.math.BigDecimal; +import org.mockito.ArgumentCaptor; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class InboundPalletAllocationRepositoryTest { @@ -42,6 +47,30 @@ class InboundPalletAllocationRepositoryTest { .doesNotThrowAnyException(); } + @Test + void restoresReservationNumbersByReservationIdWhenPalletIsReused() { + InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0); + first.setPalletId(501L); + first.setReservationNo("LR-91"); + InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0); + second.setPalletId(501L); + second.setReservationNo("LR-92"); + when(mapper.selectList(any())).thenReturn(List.of(first, second)); + + var result = new WarehousePalletLocationClient.BatchResult(2, List.of( + new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1", + new BigDecimal("1.2"), "OCCUPIED"), + new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2", + new BigDecimal("1.2"), "OCCUPIED"))); + + repository.replace(10001L, 31L, 88L, 0L, 2, result); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); + verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture()); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo) + .containsExactly("LR-91", "LR-92"); + } + private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status, int allocated, int picked) { InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 637251088..3d06d0412 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -63,4 +63,26 @@ class InboundWorkflowSqlContractTest { .contains("uk_measure_box_code") .contains("单箱当前测量字段未完整创建"); } + + @Test + @DisplayName("称重结果固化托盘快照、毛重和净重并允许托盘主数据复用") + void shouldPersistWeightPalletSnapshotsAndNetWeight() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema) + .contains("pallet_tare_weight_kg DECIMAL(18,3) NOT NULL") + .contains("gross_weight_kg DECIMAL(18,3) NULL") + .contains("net_weight_kg DECIMAL(18,3) NULL") + .contains("idx_weight_inbound_pallet") + .doesNotContain("final_weight_kg") + .doesNotContain("uk_pallet_allocation_pallet"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") + .contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") + .contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") + .contains("称重托盘快照或毛重净重字段未正确收敛") + .contains("同票托盘唯一约束仍阻止托盘主数据复用"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 4abca26ec..76018775b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -37,29 +37,50 @@ class LoadingListQueryMapperContractTest { .contains("p.preload_pushed_at IS NOT NULL") .contains("p.plan_status IN ('EDITING', 'LOADING')") .contains("active_item.deleted = 0") + .contains("fl_preload_loading_plan_link") + .contains("fl_ocean_booking_container_allocation") + .contains("fl_loading_pick_task") + .contains("pda_task_status") + .contains("customer_item.account_id = #{query.customerId}") .contains("b.warehouse_id = #{query.warehouseId}") .doesNotContain("query.accountId") .doesNotContain("PRELOAD_PUSHED"); assertThat(confirmed) .contains("FROM fl_loading_plan p") .contains("p.plan_status = 'COMPLETED'") + .contains("customer_item.account_id = #{query.customerId}") .contains("b.warehouse_id = #{query.warehouseId}") .doesNotContain("query.accountId") .doesNotContain("fl_loading_list"); } + @Test + void detailItemsExposeFrozenOrderAndActiveContainerFacts() throws Exception { + String items = sql("selectItems", Select.class); + assertThat(items) + .contains("i.order_id", "i.order_no", "b.container_no") + .contains("b.record_status='ACTIVE'") + .contains("i.loading_plan_id = #{planId}") + .contains("i.deleted = 0"); + } + @Test void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { String touch = sql("touchEditable", Update.class); String transport = sql("updateTransport", Update.class); + String confirmPreload = sql("confirmLinkedPreload", Update.class); + String confirmPlan = sql("touchForPreloadConfirmation", Update.class); String advance = sql("advance", Update.class); assertThat(touch) - .contains("plan_status = 'LOADING'") + .contains("plan_status IN ('EDITING', 'LOADING')") + .contains("preload_pushed_at IS NOT NULL") .contains("version = #{expectedVersion}") .contains("version = version + 1"); assertThat(transport) .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") + .contains("plan_status IN ('EDITING', 'LOADING')") + .contains("preload_pushed_at IS NOT NULL") .contains("#{containerNo} IS NOT NULL") .contains("fl_loading_pick_task") .doesNotContain("DELETE") @@ -69,6 +90,16 @@ class LoadingListQueryMapperContractTest { .contains("plan_status = #{toStatus}") .contains("version = #{expectedVersion}") .doesNotContain("ROLLED_BACK"); + assertThat(confirmPreload) + .contains("status = 'CONFIRMED'") + .contains("status = #{fromStatus}") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(confirmPlan) + .contains("plan_status = 'EDITING'") + .contains("preload_pushed_at IS NOT NULL") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index 86132d2aa..c2d7bc9db 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -39,6 +39,13 @@ class LoadingListRepositoryTest { row.setPlanNo("LP-41"); row.setPlanStatus("LOADING"); row.setVersion(1); + row.setContainerNo("MSCU-001"); + row.setCabinetNo("CAB-001"); + row.setPreloadPushedAt(java.time.LocalDateTime.now()); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(1L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); Page preloadPage = new Page<>(1, 20); preloadPage.setRecords(List.of(row)); @@ -51,13 +58,17 @@ class LoadingListRepositoryTest { confirmedPage.setTotal(1); when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage); - var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20)) + var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, null, 1, 20)) .records().get(0); - var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)) + var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)) .records().get(0); assertThat(preload.planId()).isEqualTo(41L); assertThat(preload.containerBookingId()).isEqualTo(61L); + assertThat(preload.containerNo()).isEqualTo("MSCU-001"); + assertThat(preload.cabinetNo()).isEqualTo("CAB-001"); + assertThat(preload.readiness().confirmationStatus()).isEqualTo("PARTIALLY_CONFIRMED"); + assertThat(preload.readiness().oceanBookingStatus()).isEqualTo("RELEASED"); assertThat(confirmed.planId()).isEqualTo(41L); assertThat(confirmed.containerBookingId()).isEqualTo(61L); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 56b16a3cb..91576c639 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -28,6 +28,15 @@ class LoadingPickPdaTaskSqlContractTest { assertThat(sql) .contains("task.task_status = 'LOADING'") + .contains("booking.record_status = 'ACTIVE'") + .contains("booking.source_type AS sourceType") + .contains("booking.record_status AS recordStatus") + .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") + .contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'") + .contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'") + .contains("pick_item.item_status IN ('PENDING', 'PICKING')") + .contains("THEN 'AWAITING_REVIEW'") + .contains("ELSE 'IN_PROGRESS' END AS workflowStatus") .contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") .contains("booking.warehouse_id AS warehouseId") .doesNotContain("tenantId"); @@ -46,4 +55,17 @@ class LoadingPickPdaTaskSqlContractTest { .contains("label.inbound_detail_id = plan_item.inbound_detail_id") .contains("label.box_code AS boxCode"); } + + @Test + void taskDetailResolvesOnlyTheActiveBookingOwnedByItsCurrentPlan() throws Exception { + String sql = LoadingPickTaskMapper.class + .getDeclaredMethod("selectActiveBookingByTask", long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan.id=task.loading_plan_id") + .contains("booking.id=plan.container_booking_id") + .contains("task.id=#{taskId}") + .contains("booking.record_status='ACTIVE'"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java index a9f9d7c47..9342ba8a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java @@ -14,8 +14,11 @@ class LoadingPickStableLookupContractTest { .getAnnotation(Select.class).value()[0]; String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no"); - assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no"); - assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no ="); + assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'") + .doesNotContain("task.container_no"); + assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'") + .doesNotContain("task.container_no"); + assertThat(legacy).contains("booking.container_no = #{containerNo}", + "booking.record_status = 'ACTIVE'").doesNotContain("task.container_no ="); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index f3b541a5c..d8204c704 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -76,7 +76,14 @@ class PdaSplitSettlementSqlContractTest { .getAnnotation(Insert.class).value()[0]; assertThat(clone) .contains("#{childOrderId},#{childOrderNo}") - .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END"); + .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END") + .contains("actual_gross_weight_kg * #{packageCount}") + .contains("actual_net_weight_kg * #{packageCount}"); + + String cloneItem = InboundSplitSettlementMapper.class.getMethod("cloneMeasurementItem", + long.class, long.class, long.class, long.class, long.class, long.class, int.class, int.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(cloneItem).doesNotContain("unit_weight_kg").doesNotContain("total_weight_kg"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java index 4bdc611a2..2e1c0f81a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java @@ -22,15 +22,19 @@ class Phase3BookingTruckPermissionSqlContractTest { private static final Pattern OCEAN_CODE = Pattern.compile("platform:firstleg:ocean-booking:[a-z-]+"); private static final Pattern TRUCK_CODE = Pattern.compile("platform:firstleg:truck-appointment:[a-z-]+"); - private static final Set OCEAN_PERMISSIONS = Set.of( + private static final Set OCEAN_CONTROLLER_PERMISSIONS = Set.of( "platform:firstleg:ocean-booking:list", "platform:firstleg:ocean-booking:create", - "platform:firstleg:ocean-booking:edit", - "platform:firstleg:ocean-booking:submit", - "platform:firstleg:ocean-booking:confirm", "platform:firstleg:ocean-booking:release", - "platform:firstleg:ocean-booking:cancel", - "platform:firstleg:ocean-booking:change", + "platform:firstleg:ocean-booking:edit", + "platform:firstleg:ocean-booking:delete"); + + private static final Set OCEAN_SQL_PERMISSIONS = Set.of( + "platform:firstleg:ocean-booking:list", + "platform:firstleg:ocean-booking:create", + "platform:firstleg:ocean-booking:release", + "platform:firstleg:ocean-booking:edit", + "platform:firstleg:ocean-booking:delete", "platform:firstleg:ocean-booking:allocate"); private static final Set TRUCK_PERMISSIONS = Set.of( @@ -40,16 +44,16 @@ class Phase3BookingTruckPermissionSqlContractTest { "platform:firstleg:truck-appointment:operate"); @Test - void controllerAuthoritiesExactlyMatchModuleAndUnifiedPermissionCodes() throws Exception { - assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_PERMISSIONS); + void controllerAuthoritiesAndPersistedPermissionCodesMatchTheirActiveContracts() throws Exception { + assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_CONTROLLER_PERMISSIONS); assertThat(authorities(PlatformTruckAppointmentController.class)).isEqualTo(TRUCK_PERMISSIONS); String oceanModule = resource("db/phase3-ocean-booking-schema.sql"); String truckModule = resource("db/phase3-truck-appointment-schema.sql"); String unified = unifiedSql(); - assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS); + assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS); assertThat(codes(truckModule, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS); - assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS); + assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS); assertThat(codes(unified, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS); } @@ -69,6 +73,8 @@ class Phase3BookingTruckPermissionSqlContractTest { .contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'") .contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'") .contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'") + .contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6") + .contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .doesNotContain("assert_phase3_booking_truck_permission_ids_available") .doesNotContain("/trucking"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java index 9811a39df..58a17c51b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java @@ -60,12 +60,16 @@ class Phase3ChannelContainerMigrationContractTest { .contains("@phase3_channel_message = MESSAGE_TEXT") .contains("渠道柜迁移失败:货柜只读触发器未恢复") .contains("渠道柜迁移失败:PDA任务只读触发器未恢复") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("已完成PDA拣货任务不可修改") .contains("NOT (OLD.channel_code <=> NEW.channel_code)") .contains("NOT (OLD.channel_name <=> NEW.channel_name)") .contains("NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)") .contains("NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("p.plan_status='COMPLETED'"); assertThat(call).isGreaterThan(0); assertThat(containerRestore).isGreaterThan(call); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index e42ec709c..62fcaae25 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -30,6 +30,10 @@ class Phase3FinalUnifiedSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_customs_case") .contains("CREATE TABLE IF NOT EXISTS fl_customs_document") .contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch") + .contains("customs_agent_id BIGINT UNSIGNED NULL") + .contains("terminal_trucking_company_id BIGINT UNSIGNED NULL") + .contains("phase3_container_supplier_snapshot_add_column") + .contains("phase3_customs_supplier_snapshot_add_column") .contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") @@ -54,6 +58,20 @@ class Phase3FinalUnifiedSqlContractTest { .contains("trg_loading_pick_operation_before_delete"); } + @Test + void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception { + String sql = normalise(Files.readString(UNIFIED)); + assertThat(sql) + .contains("DROP CHECK chk_ocean_booking_status") + .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") + .contains("DROP COLUMN failure_reason") + .contains("(status IN ('DRAFT','RELEASED'))") + .contains("platform:firstleg:ocean-booking:edit") + .contains("platform:firstleg:ocean-booking:delete") + .doesNotContain("reconcile_ocean_booking_legacy") + .doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'"); + } + @Test void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception { String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); @@ -134,8 +152,9 @@ class Phase3FinalUnifiedSqlContractTest { assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id'") - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name'"); + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'"); assertThat(sql.indexOf("CALL apply_queued_comments();", sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"))) .isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java index 3ef7e2a0f..878d7df0a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -42,6 +42,8 @@ class PriceBookSqlContractTest { .contains("destination_name VARCHAR(128)") .contains("parse_issues_json JSON NULL") .contains("DEFAULT 'PENDING_CONFIRMATION'"); + assertThat(schema) + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'"); assertThat(schema) .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 69c8962dc..b74c68455 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -22,6 +22,11 @@ class SmartLoadingCandidateMapperContractTest { .contains("i.inbound_detail_id IS NOT NULL") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("unitGrossWeightKg") + .contains("unitNetWeightKg") + .contains("o.actual_gross_weight_kg") + .contains("o.actual_net_weight_kg") + .doesNotContain("i.unit_weight_kg") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); @@ -39,6 +44,9 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("unitGrossWeightKg") + .contains("unitNetWeightKg") + .doesNotContain("i.unit_weight_kg") .contains("SELECT SUM(li.package_count)") .contains("FOR UPDATE"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 4de276cb7..d784cf5f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest { @DisplayName("平台聚合与客户明细边界及原型快照字段完整") void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_document_sequence") .contains("CREATE TABLE IF NOT EXISTS fl_container_booking") .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan") .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") @@ -61,6 +62,8 @@ class SmartLoadingSqlContractTest { @Test @DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { + assertContainerLifecycleSchema(migration()); + assertContainerLifecycleSchema(unifiedMigration()); assertThat(migration()) .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") @@ -73,6 +76,46 @@ class SmartLoadingSqlContractTest { .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } + @Test + @DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") + void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8); + assertThat(mapper) + .contains("UPDATE fl_loading_plan_item i") + .contains("p.version=#{touchedPlanVersion}") + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("i.frozen_at IS NULL") + .doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p") + .doesNotContain("p.version=p.version+1"); + assertWarehouseRemarkTriggerContract(migration()); + assertWarehouseRemarkTriggerContract(unifiedMigration()); + } + + private static void assertContainerLifecycleSchema(String sql) { + assertThat(sql) + .contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'") + .contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'") + .contains("merged_into_container_booking_id BIGINT UNSIGNED NULL") + .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") + .contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)") + .contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))") + .contains("CHECK (record_status IN ('ACTIVE','MERGED'))") + .contains("column_name='source_type'") + .contains("column_name='record_status'") + .contains("column_name='merged_into_container_booking_id'"); + } + + private static void assertWarehouseRemarkTriggerContract(String sql) { + assertThat(sql) + .contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN") + .contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted") + .contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段") + .contains("ELSEIF NOT EXISTS (") + .contains("IF OLD.frozen_at IS NOT NULL THEN") + .contains("装柜完成后的明细永久冻结"); + } + @Test @DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希") void shouldProvideSimpleCustomsDocuments() throws Exception { @@ -81,7 +124,8 @@ class SmartLoadingSqlContractTest { assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"), StandardCharsets.UTF_8)) .contains("CREATE TRIGGER trg_container_booking_before_update") - .contains("IF current_plan_status='COMPLETED' THEN") + .contains("current_plan_status IN ('LOADING','COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); } @@ -145,8 +189,11 @@ class SmartLoadingSqlContractTest { .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("装柜完成后的明细永久冻结") - .contains("装柜中仅允许修改集装箱号与航次") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("preload_status") .doesNotContain("PRELOAD_PUSHED") .doesNotContain("PRELOADING") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 97144d19d..55f66cc9b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -13,12 +13,24 @@ class TruckAppointmentMapperContractTest { @Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception { assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment"); String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0]; - assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}"); + assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}", + "fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count"); String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); + String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1"); + String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0]; + assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE") + .contains("'COMPLETED','CANCELLED','EXCEPTION'"); + String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}", + "version=#{version}", "revision=revision+1", "version=version+1"); String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0]; - assertThat(edit).contains("WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); + assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}", + "loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}", + "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", + "version=version+1", "revision=revision+1"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java new file mode 100644 index 000000000..0252bdeea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.CurrentContainerTransport; +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +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; +import static org.mockito.ArgumentMatchers.anyList; + +class UnifiedInboundTransportRepositoryTest { + private final UnifiedInboundTransportMapper mapper = mock(UnifiedInboundTransportMapper.class); + private final UnifiedInboundReadRepository repository = repository(); + + @Test + void batchKeepsParentChildAndDepartedUnitOnTheirOwnContainer() { + var oldDeparted = fact(11L, 101L, 501L, "OLD", null, LocalDateTime.parse("2026-08-01T08:00")); + var splitChild = fact(12L, 101L, 502L, "NEW", LocalDateTime.parse("2026-08-20T08:00"), null); + when(mapper.findCurrentByInboundIds(anyList())).thenReturn(List.of(oldDeparted, splitChild)); + + var result = repository.findTransports(Map.of(11L, 101L, 12L, 101L)); + + assertThat(result.get(11L).containerBookingId()).isEqualTo(501L); + assertThat(result.get(11L).actualSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-01T08:00")); + assertThat(result.get(12L).containerBookingId()).isEqualTo(502L); + assertThat(result.get(12L).estimatedSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-20T08:00")); + verify(mapper).findCurrentByInboundIds(anyList()); + } + + @Test + void noContainerReturnsNoFact() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of()); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + @Test + void multipleEffectiveContainersAreAnInvariantConflict() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 101L, 501L, "A", null, null), fact(11L, 101L, 502L, "B", null, null))); + assertThatThrownBy(() -> repository.findTransports(Map.of(11L, 101L))) + .isInstanceOf(BizException.class).hasMessageContaining("多个有效柜次"); + } + + @Test + void filtersRowsOutsideInboundTenantOwnership() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 999L, 501L, "OTHER", null, null))); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + private UnifiedInboundReadRepository repository() { + var value = new UnifiedInboundReadRepository(); + ReflectionTestUtils.setField(value, "transports", mapper); + return value; + } + + private CurrentContainerTransport fact(Long inboundId, Long accountId, Long bookingId, String containerNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt) { + return new CurrentContainerTransport(inboundId, accountId, bookingId, containerNo, "CAB", "VESSEL", "V1", + estimatedSailingAt, actualSailingAt, null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java new file mode 100644 index 000000000..b3fbc7bca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnifiedInboundTransportSqlContractTest { + @Test + void queriesCurrentBusinessUnitContainersInOneBatchFromAuthoritativeBooking() throws Exception { + String sql = String.join(" ", UnifiedInboundTransportMapper.class + .getMethod("findCurrentByInboundIds", java.util.List.class).getAnnotation(Select.class).value()); + assertThat(sql).contains("fl_loading_plan_item", "i.deleted = 0", + "p.plan_status IN ('LOADING', 'COMPLETED')", "fl_container_booking", + "estimated_sailing_at", "actual_sailing_at", "estimated_port_arrival_at", + "actual_port_arrival_at", " List.of(insertedItem.get())); + when(repository.updateMeasurement(any())).thenReturn(true); + + CartonMeasurementResult result = service.upsertManualForInbound( + 10001L, 90001L, 70001L, acceptanceCommand()); + + ArgumentCaptor measurement = + ArgumentCaptor.forClass(InboundMeasurementEntity.class); + verify(repository).insertMeasurement(measurement.capture()); + assertThat(result.created()).isTrue(); + assertThat(result.measurementId()).isEqualTo(measurement.getValue().getId()); + assertThat(measurement.getValue().getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL); + assertThat(measurement.getValue().getSourceCode()).isEqualTo("PLATFORM"); + assertThat(measurement.getValue().getStatus()).isEqualTo(MeasurementBatchService.STATUS_RECEIVED); + assertThat(insertedItem.get().getMeasurementId()).isEqualTo(measurement.getValue().getId()); + verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + @Test void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() { InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4); @@ -145,7 +172,7 @@ class CartonMeasurementServiceTest { when(repository.updateMeasurement(measurement)).thenReturn(true); CartonMeasurementCommand oversize = new CartonMeasurementCommand( "RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"), - new BigDecimal("50.100"), new BigDecimal("12.500"), "MJ-01", + new BigDecimal("50.100"), "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), "FBA-SCAN-001"); @@ -248,14 +275,13 @@ class CartonMeasurementServiceTest { } @Test - void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() { + void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); measurement.setTotalPackageCount(1); measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); measurement.setVolumeWeightKg(new BigDecimal("10.020")); InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); - item.setUnitWeightKg(new BigDecimal("10.880")); item.setOuterLengthCm(new BigDecimal("50")); item.setOuterWidthCm(new BigDecimal("40")); item.setOuterHeightCm(new BigDecimal("30")); @@ -266,13 +292,52 @@ class CartonMeasurementServiceTest { when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); when(repository.updateItem(item)).thenReturn(true); when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of()); when(repository.updateMeasurement(measurement)).thenReturn(true); when(repository.updateInbound(inbound)).thenReturn(true); service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); assertThat(inbound.getTotalPackageCount()).isEqualTo(5); - assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("54.400"); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); + verify(repository).updateInbound(inbound); + } + + @Test + void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setOuterLengthCm(new BigDecimal("50")); + item.setOuterWidthCm(new BigDecimal("40")); + item.setOuterHeightCm(new BigDecimal("30")); + item.setUnitVolumeCbm(new BigDecimal("0.1496")); + item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); + InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity(); + confirmedWeight.setRecognitionStatus("CONFIRMED"); + confirmedWeight.setGrossWeightKg(new BigDecimal("30.000")); + confirmedWeight.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of(confirmedWeight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500"); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); verify(repository).updateInbound(inbound); @@ -280,7 +345,7 @@ class CartonMeasurementServiceTest { private CartonMeasurementCommand command() { return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), - new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"), + new BigDecimal("40.200"), new BigDecimal("30.300"), "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), "FBA-SCAN-001"); @@ -289,7 +354,7 @@ class CartonMeasurementServiceTest { private CartonMeasurementCommand acceptanceCommand() { return new CartonMeasurementCommand( "RK-001#5-1", new BigDecimal("59.500"), new BigDecimal("41.200"), - new BigDecimal("31.300"), new BigDecimal("13.600"), "MJ-ADMIN-E2E-02", + new BigDecimal("31.300"), "MJ-ADMIN-E2E-02", LocalDateTime.parse("2026-08-08T10:01:00"), new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), "FBA-SCAN-002"); @@ -314,7 +379,7 @@ class CartonMeasurementServiceTest { item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId); item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount); item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40")); - item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10")); + item.setHeightCm(new BigDecimal("30")); item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm"); return item; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index 4227144e2..d50e9d782 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -2,11 +2,13 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; import com.baiyue.firstleg.entity.FeeItemEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; import com.baiyue.firstleg.repository.FeeItemRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import org.junit.jupiter.api.Test; @@ -20,6 +22,8 @@ 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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -31,8 +35,10 @@ class ContainerFeeDetailServiceTest { private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class); private final FeeItemRepository feeItems = mock(FeeItemRepository.class); private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class); + private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class); + private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class); private final ContainerFeeDetailService service = - new ContainerFeeDetailService(repository, loading, feeItems, outbox); + new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots); @Test void rejectsContainerThatDoesNotContainCustomerOrders() { @@ -55,9 +61,13 @@ class ContainerFeeDetailServiceTest { approved.setId(51L); approved.setTenantId(10001L); approved.setLoadingPlanId(41L); + approved.setFeeItemId(11L); + approved.setDirection("RECEIVABLE"); approved.setSourceVersion(1); - approved.setSettlementIncluded(true); when(repository.find(10001L, 41L, 51L)).thenReturn(approved); + when(feeItems.findItem(11L)).thenReturn(item()); + when(repository.updateSupplierSnapshot(eq(10001L), eq(41L), eq(51L), + eq(null), eq(null), eq(null), eq(9L))).thenReturn(1); when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1); service.approve(10001L, 41L, 51L, 9L, "admin"); @@ -69,37 +79,107 @@ class ContainerFeeDetailServiceTest { @Test void adjustmentIncrementsSourceVersionAndReturnsToReview() { ownedPlan(); - FeeItemEntity item = item(); - when(feeItems.findItem(11L)).thenReturn(item); - ContainerFeeDetailEntity current = new ContainerFeeDetailEntity(); - current.setId(51L); - current.setTenantId(10001L); - current.setLoadingPlanId(41L); - current.setSourceVersion(1); + ContainerFeeDetailEntity current = approvedFee(1); + ContainerFeeDetailEntity adjusted = approvedFee(2); + adjusted.setBusinessDate(LocalDate.of(2026, 8, 12)); + adjusted.setAmount(new BigDecimal("325.00")); + adjusted.setReviewStatus("PENDING"); when(repository.find(10001L, 41L, 51L)).thenReturn(current); - when(repository.adjust(any(), eq(1))).thenReturn(1); + when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), + eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any())) + .thenReturn(1); + when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted); - service.adjust(10001L, 41L, 51L, 1, 9L, request(true)); + ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00")); - ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); - verify(repository).adjust(captor.capture(), eq(1)); - assertThat(captor.getValue().getSourceVersion()).isEqualTo(2); - assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING"); - assertThat(captor.getValue().getPartnerId()).isEqualTo(71L); - assertThat(captor.getValue().getPartnerName()).isEqualTo("现有往来单位"); + assertThat(result.getSourceVersion()).isEqualTo(2); + assertThat(result.getReviewStatus()).isEqualTo("PENDING"); + assertThat(result.getFeeItemId()).isEqualTo(11L); + assertThat(result.getDirection()).isEqualTo("PAYABLE"); + assertThat(result.getSupplierId()).isEqualTo(71L); + assertThat(result.getCurrency()).isEqualTo("CNY"); + assertThat(result.getRemark()).isEqualTo("原费用备注"); + verify(feeItems, never()).findItem(anyLong()); } @Test - void payableRequiresCounterpartySnapshot() { + void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() { + ownedPlan(); + ContainerFeeDetailEntity replay = approvedFee(2); + replay.setReviewStatus("PENDING"); + replay.setBusinessDate(LocalDate.of(2026, 8, 12)); + replay.setAmount(new BigDecimal("325.00")); + when(repository.find(10001L, 41L, 51L)).thenReturn(replay); + + assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0"))) + .isSameAs(replay); + + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void staleOrDifferentReplayIsRejectedWithoutWriting() { + ownedPlan(); + ContainerFeeDetailEntity current = approvedFee(2); + current.setReviewStatus("PENDING"); + current.setBusinessDate(LocalDate.of(2026, 8, 12)); + current.setAmount(new BigDecimal("325.00")); + when(repository.find(10001L, 41L, 51L)).thenReturn(current); + + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "326.00"))).isInstanceOf(BizException.class) + .hasMessageContaining("当前版本的已审核"); + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void concurrentVersionChangeReturnsConflictWithoutPublishing() { + ownedPlan(); + when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1)); + when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any())) + .thenReturn(0); + + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class) + .hasMessageContaining("当前版本的已审核"); + + verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(), + anyInt(), any()); + } + + @Test + void voidedOrUnapprovedFeeCannotBeAdjusted() { + ownedPlan(); + ContainerFeeDetailEntity voided = approvedFee(1); + voided.setSourceStatus("VOIDED"); + when(repository.find(10001L, 41L, 51L)).thenReturn(voided); + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class); + + ContainerFeeDetailEntity pending = approvedFee(1); + pending.setReviewStatus("PENDING"); + when(repository.find(10001L, 41L, 51L)).thenReturn(pending); + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class); + + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void payableRequiresSupplierSnapshot() { ownedPlan(); when(feeItems.findItem(11L)).thenReturn(item()); - var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", + var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", - true, null, null, "整柜拖车费"); + null, "整柜拖车费"); - assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner)) - .isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位"); + assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier)) + .isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商"); verify(repository, never()).insert(any()); } @@ -110,13 +190,13 @@ class ContainerFeeDetailServiceTest { var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", - true, 71L, "不应保留", "整柜操作费"); + 71L, "整柜操作费"); service.create(10001L, 41L, 9L, request); ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); verify(repository).insert(captor.capture()); - assertThat(captor.getValue().getPartnerId()).isNull(); - assertThat(captor.getValue().getPartnerName()).isNull(); + assertThat(captor.getValue().getSupplierId()).isNull(); + assertThat(captor.getValue().getSupplierName()).isNull(); } private void ownedPlan() { @@ -139,9 +219,34 @@ class ContainerFeeDetailServiceTest { return value; } - private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) { + private static ContainerFeeDetailEntity approvedFee(int version) { + ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); + value.setId(51L); + value.setTenantId(10001L); + value.setLoadingPlanId(41L); + value.setFeeItemId(11L); + value.setFeeItemCodeSnapshot("TRUCKING"); + value.setFeeItemNameSnapshot("拖车费"); + value.setDirection("PAYABLE"); + value.setSupplierId(71L); + value.setSupplierName("现有供应商"); + value.setCurrency("CNY"); + value.setRemark("原费用备注"); + value.setBusinessDate(LocalDate.of(2026, 8, 11)); + value.setAmount(new BigDecimal("300.00")); + value.setSourceStatus("ACTIVE"); + value.setReviewStatus("APPROVED"); + value.setSourceVersion(version); + return value; + } + + private static AdjustRequest adjustment(int version, String amount) { + return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount)); + } + + private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) { return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", - settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费"); + 71L, "整柜拖车费"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index b2740c46f..0a7ea777f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -5,6 +5,10 @@ import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -13,6 +17,7 @@ import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -20,17 +25,98 @@ import static org.mockito.Mockito.*; class ContainerOverviewServiceTest { @Mock ContainerRepository containers; @Mock ContainerOverviewMapper mapper; + @Mock LoadingEvidenceFileClient evidenceFiles; @InjectMocks ContainerOverviewService service; - @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + @BeforeEach void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test void overviewExposesAwaitingReviewWorkflowAndFirstlegPhotoDownloadPath() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(41L); booking.setVersion(3); booking.setPlanId(52L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.itemCount(61L)).thenReturn(2); + when(mapper.completedItemCount(61L)).thenReturn(2); + when(mapper.unresolvedItemCount(61L)).thenReturn(0); + when(mapper.photos(52L)).thenReturn(java.util.List.of( + new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), + new ContainerOverviewMapper.PhotoRow( + 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); + when(mapper.documents(41L)).thenReturn(java.util.List.of()); + when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); + when(mapper.transport(41L)).thenReturn(java.util.List.of()); + when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); + when(mapper.pickExceptionReasons(61L)).thenReturn(java.util.List.of()); + + var overview = service.overview(41L); + + assertThat(overview.pda().taskStatus()).isEqualTo("LOADING"); + assertThat(overview.pda().workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(overview.pda().photos().get(0).downloadPath()) + .isEqualTo("/api/platform/v1/containers/41/pda/photos/101/download"); + assertThat(overview.pda().photos()).hasSize(2); + assertThat(overview.pda().photos().get(1).status()).isEqualTo("VOID"); + assertThat(overview.pda().photos().get(1).downloadPath()).isNull(); + assertThat(overview.pda().photoCounts()).singleElement().satisfies(count -> { + assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); + assertThat(count.count()).isEqualTo(1); + }); + } + + @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()))); + when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3})); + + var download = service.downloadPhoto(41L, 101L); + + assertThat(download.fileName()).isEqualTo("door.jpg"); + assertThat(download.contentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(download.content()).containsExactly(1, 2, 3); + } + + @Test void rejectsPhotoFromAnotherContainerBeforeCallingFileService() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of()); + + assertThatThrownBy(() -> service.downloadPhoto(41L, 999L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜"); + verifyNoInteractions(evidenceFiles); + } + + @Test void rejectsVoidedPhotoBeforeCallingFileService() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "void.jpg", "VOID", LocalDateTime.now()))); + + assertThatThrownBy(() -> service.downloadPhoto(41L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜"); + verifyNoInteractions(evidenceFiles); + } @Test void markLocksContainerAndPersistsIdempotentImmutableHistory() { ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3); - when(containers.findForUpdate(41L)).thenReturn(booking); + when(containers.findActiveForUpdate(41L)).thenReturn(booking); service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal")); - verify(containers).findForUpdate(41L); + verify(containers).findActiveForUpdate(41L); verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L), eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 19066d250..9ac608ba6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -3,8 +3,12 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +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.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; @@ -13,6 +17,12 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.BeforeEach; @@ -23,14 +33,14 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.time.LocalDateTime; +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.anyLong; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @DisplayName("货柜基础资料服务") @@ -41,58 +51,83 @@ class ContainerServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; + @MockBean private PriceBookQueryRepository priceBooks; + @MockBean private OceanBookingRepository oceanBookings; + @MockBean private WarehouseSupplierSnapshotService suppliers; + @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private ContainerTransportHistoryMapper transportHistories; @Resource private ContainerService service; @BeforeEach void setUpWarehouse() { + when(documentNumbers.next("CB")).thenReturn("CB-260818-0001"); + when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001"); when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); + when(suppliers.trucking(202L, "拖车公司A")) + .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE")); } @Test - @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") - void createsContainerBooking() { - ContainerBookingEntity created = service.create(9L, "admin", request("柜-001")); + @DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号") + void createsSimulationContainerBooking() { + ChannelRow channel = channel(); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel)); + ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation()); ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); verify(repository).insert(captor.capture()); ContainerBookingEntity saved = captor.getValue(); assertThat(created).isSameAs(saved); - assertThat(saved.getBookingNo()).startsWith("CB"); - assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); + assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001"); + assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001"); + assertThat(saved.getContainerNo()).isNull(); + assertThat(saved.getOceanBookingId()).isNull(); + assertThat(saved.getChannelName()).isEqualTo("美西海运"); assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); - assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); - assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING"); } @Test - @DisplayName("智能装柜草稿阶段允许完整编辑") + @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") + void rejectsSimulationWithNonCanonicalSpecification() { + SimulationCreateRequest invalid = new SimulationCreateRequest( + "SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000")); + + assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid)) + .isInstanceOf(BizException.class) + .hasMessageContaining("容量和限重"); + verify(repository, never()).insert(any()); + } + + @Test + @DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号") void updatesAllFieldsWhileEditing() { ContainerBookingEntity current = booking("EDITING", 2); when(repository.find(11L)).thenReturn(current); when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); - service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))); + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request())); - assertThat(current.getCabinetNo()).isEqualTo("柜-NEW"); + assertThat(current.getCabinetNo()).isEqualTo("柜-001"); verify(repository).updateDraft(current, 2, 9L, "admin"); } @Test - @DisplayName("待装柜阶段允许完整编辑") + @DisplayName("待装柜阶段完整编辑时保留稳定柜次号") void updatesAllFieldsWhileWaitingForLoading() { ContainerBookingEntity current = booking("WAIT_LOADING", 2); when(repository.find(11L)).thenReturn(current); when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); - service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜 NEW"))); + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request())); - assertThat(current.getCabinetNo()).isEqualTo("柜 NEW"); + assertThat(current.getCabinetNo()).isEqualTo("柜-001"); verify(repository).updateDraft(current, 2, 9L, "admin"); } @@ -102,7 +137,7 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(booking("LOADING", 2)); assertThatThrownBy(() -> service.updateDraft( - 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + 11L, 9L, "admin", new FullUpdateRequest(2, request()))) .isInstanceOf(BizException.class) .hasMessageContaining("只能修改集装箱号和航次"); @@ -143,9 +178,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU1234567", "V002", 2)); - verify(loadingPickService).begin(org.mockito.ArgumentMatchers.eq(31L), - org.mockito.ArgumentMatchers.eq("MSCU1234567"), org.mockito.ArgumentMatchers.eq(9L), - org.mockito.ArgumentMatchers.eq("admin"), any()); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test @@ -165,8 +198,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU7654321", "V002", 2)); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), any(), any()); - verify(smartLoadingRepository, never()).items(31L); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test @@ -208,30 +240,18 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(booking("EDITING", 3)); assertThatThrownBy(() -> service.updateDraft( - 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + 11L, 9L, "admin", new FullUpdateRequest(2, request()))) .isInstanceOf(BizException.class) .hasMessageContaining("刷新后重试"); } - @Test - @DisplayName("新增时校验重复集装箱号和内部柜号") - void rejectsDuplicateNumbers() { - when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true); - - assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) - .isInstanceOf(BizException.class) - .hasMessageContaining("集装箱号已存在"); - - verify(repository, never()).insert(any()); - } - @Test @DisplayName("虚拟柜拒绝选择非国内中转仓") void rejectsNonDomesticWarehouse() { when(warehouses.active(anyString(), anyLong(), anyLong())) .thenReturn(activeWarehouse(101L, "FBA", "美国目的仓")); - assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation())) .isInstanceOf(BizException.class) .hasMessageContaining("国内中转仓"); @@ -241,7 +261,9 @@ class ContainerServiceTest { @Test @DisplayName("虚拟柜仓库名称以仓库主数据为准") void replacesRequestedWarehouseName() { - service.create(9L, "admin", request("柜-001")); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel())); + service.createSimulation(9L, "admin", simulation()); ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); verify(repository).insert(captor.capture()); @@ -254,13 +276,107 @@ class ContainerServiceTest { when(warehouses.active(anyString(), anyLong(), anyLong())) .thenThrow(new IllegalStateException("warehouse service unavailable")); - assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation())) .isInstanceOf(BizException.class) .hasMessageContaining("暂时不可用"); verify(repository, never()).insert(any()); } + @Test + @DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份") + void linksReleasedBookingAfterPreloading() { + ContainerBookingEntity container = booking("EDITING", 2); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); + container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000")); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000")); + when(repository.find(11L)).thenReturn(container, container); + when(oceanBookings.find(21L)).thenReturn(booking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.allocations(21L)).thenReturn(List.of()); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), + same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); + + ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)); + + assertThat(result).isSameAs(container); + assertThat(container.getCabinetNo()).isEqualTo("柜-001"); + verify(repository, never()).hasActiveLoadingItems(11L); + verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), + same(booking), eq(2), eq(9L), eq("admin")); + verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); + } + + @Test + @DisplayName("未发布订舱不可关联") + void rejectsDraftBooking() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(21L); booking.setStatus("DRAFT"); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(booking); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("已发布"); + } + + @Test + @DisplayName("订舱箱型需求达到数量上限时拒绝关联") + void rejectsRequirementOverAllocation() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(21L); booking.setStatus("RELEASED"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(1); + OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); + allocation.setRequirementId(31L); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(booking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation)); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("数量上限"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + + @Test + @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") + void unlinksEmptySimulationDirectly() { + ContainerBookingEntity linked = booking("WAIT_LOADING", 2); + linked.setOceanBookingId(21L); + ContainerBookingEntity detached = booking("WAIT_LOADING", 3); + when(repository.find(11L)).thenReturn(linked, detached); + when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1); + + ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin", + new OceanBookingUnlinkRequest(2, "换绑")); + + assertThat(result).isSameAs(detached); + verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin"); + verify(oceanBookings).deleteAllocation(11L); + } + + @Test + @DisplayName("首次关联遇到有效装柜证据时拒绝") + void rejectsLinkWhenEvidenceExists() { + ContainerBookingEntity container = booking("EDITING", 2); + when(repository.find(11L)).thenReturn(container); + when(repository.hasEvidence(11L)).thenReturn(true); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("证据"); + verifyNoInteractions(oceanBookings); + } + private static ApiResponse activeWarehouse(long warehouseId, String type, String name) { @@ -278,12 +394,20 @@ class ContainerServiceTest { return value; } - private static SaveRequest request(String cabinetNo) { - return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮", - "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", - "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A", + private static SaveRequest request() { + return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮", + "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, LocalDateTime.of(2026, 8, 25, 8, 0), null, LocalDateTime.of(2026, 8, 27, 8, 0), null); } + + private static SimulationCreateRequest simulation() { + return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000")); + } + + private static ChannelRow channel() { + ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 40699bec3..823e35dd3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -31,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference; 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.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; @@ -90,6 +91,23 @@ class FbaPricingServiceTest { assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED"); assertThat(fee.getValue().getChargeRuleId()).isNull(); assertThat(fee.getValue().getCarrierId()).isNull(); + assertThat(fee.getValue().getCustomerId()).isEqualTo(11L); + } + + @Test + @DisplayName("缺少订单客户归属时拒绝生成对客费用") + void rejectsMissingOrderCustomerOwnership() { + readyRepository(); + InboundPricingEntity inbound = inbound("200.000", "0.2000"); + inbound.setCustomerId(null); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + assertThatThrownBy(() -> service.calculate( + TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer")) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少订单客户归属"); + verify(priceBookEstimator, never()).estimate(any()); + verify(repository, never()).insertFee(any()); } @Test @@ -125,6 +143,8 @@ class FbaPricingServiceTest { assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", new BigDecimal("167.000")); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); } @Test @@ -284,7 +304,9 @@ class FbaPricingServiceTest { doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; }) .when(repository).insertIdempotency(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); - when(repository.supersedeSent(TENANT, 501L)).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any())) + .thenReturn(1); doAnswer(invocation -> { InboundFeeEntity fee = invocation.getArgument(0); fee.setId(502L); @@ -296,18 +318,81 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1"); + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正"), "adjust-1"); assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("SENT"); assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); - verify(repository).supersedeSent(TENANT, 501L); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000"); + verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); } + @Test + @DisplayName("初算费用调整后保持初算状态且不发布财务来源") + void adjustsCalculatedFeeWithoutPublishingIt() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity current = savedFee("CALCULATED"); + AtomicReference inserted = new AtomicReference<>(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-calculated")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { + InboundFeeEntity fee = invocation.getArgument(0); + fee.setId(503L); + inserted.set(fee); + return null; + }).when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get()); + + FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正"), "adjust-calculated"); + + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("费用版本已变化时拒绝覆盖当前版本") + void rejectsAdjustmentWithStaleFeeVersion() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + current.setFeeVersion(2); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + BigDecimal.ZERO, "客服修正"), "stale-adjust")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) @@ -336,7 +421,8 @@ class FbaPricingServiceTest { value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A"); value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); - value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); + value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000"))); + value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6971a2f77..94edacb07 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -43,6 +43,8 @@ class FeeManagementServiceTest { private FbaPricingRepository pricingRepository; @MockBean private FinanceFeeSourceOutboxService financeOutbox; + @MockBean + private WarehouseSupplierSnapshotService suppliers; @Resource private FeeItemService feeItems; @Resource @@ -96,6 +98,8 @@ class FeeManagementServiceTest { assertThat(created.getId()).isEqualTo(11L); assertThat(created.getAllocationFee()).isTrue(); assertThat(created.getWarehouseReceivable()).isTrue(); + assertThat(created.getWarehousePayable()).isTrue(); + assertThat(created.getSupplierCategory()).isNull(); assertThat(created.getFeeCode()).startsWith("MANUAL_"); assertThat(created.getTenantId()).isZero(); assertThat(created.getCreatedByName()).isEqualTo("admin"); @@ -183,13 +187,10 @@ class FeeManagementServiceTest { inbound.setCompanyName("客户A有限公司"); when(repository.findInbound(10001L, 21L)).thenReturn(inbound); when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); - when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); - doAnswer(invocation -> { - FeeItemEntity value = invocation.getArgument(0); - assertThat(value.getTenantId()).isZero(); - value.setId(11L); - return null; - }).when(repository).insertItem(any()); + FeeItemEntity freightItem = item(true, false); + freightItem.setId(11L); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); InboundFeeEntity fee = new InboundFeeEntity(); fee.setId(501L); @@ -223,7 +224,57 @@ class FeeManagementServiceTest { assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198"); assertThat(saved.getReviewStatus()).isEqualTo("APPROVED"); assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED"); - assertThat(saved.getSettlementIncluded()).isTrue(); + } + + @Test + @DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行") + void materializesMissingLatestCalculatedFreightOnList() { + InboundOrderEntity inbound = inbound(); + inbound.setCustomerId(101L); + inbound.setCustomerName("客户A"); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of()); + FeeItemEntity freightItem = item(true, false); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2"); + fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED"); + fee.setActualWeightKg(new BigDecimal("100.0000")); + fee.setActualVolumeCbm(new BigDecimal("1.000000")); + fee.setChargeableQuantity(new BigDecimal("167.0000")); + fee.setChargeUnit("KG"); fee.setCurrency("USD"); + fee.setTotalAmount(new BigDecimal("354.00")); + fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0)); + when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + details.list(10001L, 21L, false); + + verify(repository).insertDetail(any(InboundFeeDetailEntity.class)); + verify(repository).listDetails(10001L, 21L, false); + } + + @Test + @DisplayName("系统头程运费项目缺失时拒绝运行期补建") + void rejectsMissingSystemFreightItem() { + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeStatus("SENT"); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + assertThatThrownBy(() -> details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("FBA头程运费未初始化"); + verify(repository, never()).insertItem(any()); } @Test @@ -276,26 +327,40 @@ class FeeManagementServiceTest { @Test @DisplayName("审核只更新待审核明细") void approvesPendingDetail() { - when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity()); + InboundFeeDetailEntity pending = new InboundFeeDetailEntity(); + pending.setId(31L); + pending.setTenantId(10001L); + pending.setInboundId(21L); + pending.setFeeItemId(11L); + pending.setDirection("RECEIVABLE"); + pending.setSourceType("MANUAL"); + pending.setSourceFeeVersion(1); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.updateDetail(pending)).thenReturn(1); when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); details.approve(10001L, 21L, 31L, 9L, "admin"); verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null); + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), + eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); } @Test - @DisplayName("审核通过且参与结算的人工费用写入Finance事件") + @DisplayName("审核通过的人工费用自动写入Finance事件") void publishesApprovedSettlementFee() { InboundFeeDetailEntity approved = new InboundFeeDetailEntity(); approved.setId(31L); approved.setTenantId(10001L); approved.setInboundId(21L); approved.setFeeItemId(11L); + approved.setDirection("RECEIVABLE"); approved.setSourceType("MANUAL"); approved.setSourceFeeVersion(1); - approved.setSettlementIncluded(true); when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.updateDetail(approved)).thenReturn(1); when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); details.approve(10001L, 21L, 31L, 9L, "admin"); @@ -304,6 +369,53 @@ class FeeManagementServiceTest { eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); } + @Test + @DisplayName("自动头程运费派生明细不能在费用明细中二次审核") + void rejectsReviewingDerivedFreight() { + InboundFeeDetailEntity derived = new InboundFeeDetailEntity(); + derived.setSourceType("FBA_PRICING"); + derived.setReviewStatus("PENDING"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived); + + assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("只读派生"); + + verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null); + } + + @Test + @DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本") + void versionsApprovedManualAdjustment() { + InboundFeeDetailEntity approved = new InboundFeeDetailEntity(); + approved.setId(31L); + approved.setTenantId(10001L); + approved.setInboundId(21L); + approved.setSourceType("MANUAL"); + approved.setSourceFeeVersion(1); + approved.setReviewStatus("APPROVED"); + approved.setPublicationStatus("PUBLISHED"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L), + eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1); + doAnswer(invocation -> { + ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); + return null; + }).when(repository).insertDetail(any()); + + InboundFeeDetailEntity next = details.adjust( + 10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE")); + + assertThat(next.getId()).isEqualTo(32L); + assertThat(next.getSourceFeeId()).isEqualTo(31L); + assertThat(next.getSourceFeeVersion()).isEqualTo(2); + assertThat(next.getReviewStatus()).isEqualTo("PENDING"); + assertThat(next.getPublicationStatus()).isEqualTo("DRAFT"); + verify(repository).insertDetail(next); + } + @Test @DisplayName("作废已入池人工费用引用当前版本并发布VOIDED") void voidsPublishedSettlementFee() { @@ -314,7 +426,6 @@ class FeeManagementServiceTest { current.setFeeItemId(11L); current.setSourceType("MANUAL"); current.setSourceFeeVersion(1); - current.setSettlementIncluded(true); InboundFeeDetailEntity voided = new InboundFeeDetailEntity(); voided.setId(31L); voided.setTenantId(10001L); @@ -322,7 +433,6 @@ class FeeManagementServiceTest { voided.setFeeItemId(11L); voided.setSourceType("MANUAL"); voided.setSourceFeeVersion(1); - voided.setSettlementIncluded(true); when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided); when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1); @@ -364,6 +474,7 @@ class FeeManagementServiceTest { inbound.setDestinationName("ONT8"); inbound.setTotalPackageCount(10); inbound.setActualGrossWeightKg(new BigDecimal("10.5000")); + inbound.setActualNetWeightKg(new BigDecimal("10.5000")); inbound.setActualVolumeCbm(new BigDecimal("1.250000")); inbound.setVolumeWeightKg(new BigDecimal("208.3333")); inbound.setChargeableWeightKg(new BigDecimal("208.3333")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java index 5d334f197..51c53c777 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java @@ -3,10 +3,10 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot; import com.baiyue.firstleg.entity.FeeItemEntity; -import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity; import com.baiyue.firstleg.entity.InboundFeeDetailEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; import com.baiyue.firstleg.repository.FeeItemRepository; import org.junit.jupiter.api.Test; @@ -19,7 +19,7 @@ import static org.mockito.Mockito.when; class FinanceFeeSourceSnapshotServiceTest { private final FeeItemRepository inboundFees = mock(FeeItemRepository.class); - private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class); + private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class); private final FinanceFeeSourceSnapshotService service = new FinanceFeeSourceSnapshotService(inboundFees, containerFees); @@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest { detail.setCurrency("CNY"); detail.setReviewStatus("APPROVED"); detail.setPublicationStatus("PUBLISHED"); - detail.setSettlementIncluded(true); - when(inboundFees.findDetail(21L, 31L)).thenReturn(detail); + when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail); InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(21L); inbound.setOrderId(81L); @@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest { item.setFeeCode("STORAGE"); when(inboundFees.findItem(11L)).thenReturn(item); - FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L); + FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2); assertThat(snapshot.customerId()).isEqualTo(10001L); assertThat(snapshot.orderId()).isEqualTo(81L); assertThat(snapshot.feeVersion()).isEqualTo(2); assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}"); detail.setPublicationStatus("VOIDED"); - assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash()); + assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash()); } @Test void rejectsFeeThatDoesNotBelongToInboundPath() { InboundFeeDetailEntity detail = new InboundFeeDetailEntity(); detail.setInboundId(22L); - when(inboundFees.findDetail(21L, 31L)).thenReturn(detail); + when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail); - assertThatThrownBy(() -> service.inbound(21L, 31L)) + assertThatThrownBy(() -> service.inbound(21L, 31L, 1)) .isInstanceOf(BizException.class).hasMessageContaining("不存在"); } @Test void containerSnapshotPublishesPayableCounterpartyIdentity() { - ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity(); - detail.setId(51L); + ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity(); + detail.setSourceFeeId(51L); detail.setTenantId(10001L); detail.setLoadingPlanId(41L); detail.setContainerBookingId(61L); detail.setContainerNo("CONT-41"); detail.setFeeItemId(11L); - detail.setFeeItemCodeSnapshot("TRUCKING"); - detail.setFeeItemNameSnapshot("拖车费"); + detail.setFeeItemCode("TRUCKING"); + detail.setFeeItemName("拖车费"); detail.setDirection("PAYABLE"); - detail.setPartnerId(71L); - detail.setPartnerName("现有往来单位"); + detail.setSupplierId(71L); + detail.setSupplierName("现有供应商"); + detail.setSupplierCategory("TRUCKING"); detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12)); detail.setAmount(new BigDecimal("300.00")); detail.setCurrency("CNY"); detail.setReviewStatus("APPROVED"); - detail.setSourceStatus("ACTIVE"); detail.setSourceVersion(2); - detail.setSettlementIncluded(true); - when(containerFees.find(41L, 51L)).thenReturn(detail); + when(containerFees.find(41L, 51L, 2)).thenReturn(detail); - FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L); + FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2); - assertThat(snapshot.partnerId()).isEqualTo(71L); - assertThat(snapshot.partnerName()).isEqualTo("现有往来单位"); + assertThat(snapshot.supplierId()).isEqualTo(71L); + assertThat(snapshot.supplierName()).isEqualTo("现有供应商"); + assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING"); assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..911518b9c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +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 FirstlegDocumentNumberGeneratorTest { + 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(FirstlegDocumentNumberGenerator.class); + context.refresh(); + + assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull(); + } + } + + @Test + void formatsAllOperationalDocumentPrefixes() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) + .thenReturn(1L, 1L, 1L, 1L, 1L); + FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); + + assertThat(generator.next("OB")).isEqualTo("OB-260818-0001"); + assertThat(generator.next("TA")).isEqualTo("TA-260818-0001"); + assertThat(generator.next("CB")).isEqualTo("CB-260818-0001"); + assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001"); + assertThat(generator.next("LP")).isEqualTo("LP-260818-0001"); + + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), + eq("OB:20260818")); + } + + @Test + void rejectsUnsupportedTypeAndDailyOverflow() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); + assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class); + + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); + assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java new file mode 100644 index 000000000..821a4164d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +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.verify; +import static org.mockito.Mockito.when; + +class IamCustomerSnapshotServiceTest { + @Mock IamCustomerSnapshotClient client; + private IamCustomerSnapshotService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new IamCustomerSnapshotService(); + ReflectionTestUtils.setField(service, "client", client); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void resolvesExactlyOneActiveCustomerAndUsesServerName() { + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + + var result = service.active(7L, "Canonical Customer"); + + assertThat(result.tenantName()).isEqualTo("Canonical Customer"); + verify(client).page("internal-token", 1, 2, null, "ACTIVE", 7L); + } + + @Test + void rejectsMissingInactiveOrClientSuppliedDifferentName() { + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2, 0, 0))); + assertThatThrownBy(() -> service.active(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用"); + + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + assertThatThrownBy(() -> service.active(7L, "Forged Customer")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index dd53f1372..868af9ece 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -60,8 +60,8 @@ class InboundCartonLabelServiceTest { assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); @@ -102,8 +102,8 @@ class InboundCartonLabelServiceTest { .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); verify(repository, never()).insert(any()); } @@ -122,7 +122,7 @@ class InboundCartonLabelServiceTest { assertThat(result.totalCount()).isEqualTo(2); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2"); + .containsExactly("PCHK2608140001#2-1", "PCHK2608140001#2-2"); } @Test @@ -154,35 +154,35 @@ class InboundCartonLabelServiceTest { var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#1-1"); + .containsExactly("PCHK2608140001#1-1"); verify(repository, never()).insert(any()); } @Test void resolvesBoxCodeInsideCustomerBoundary() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); + var resolved = service.resolveByBoxCode(10001L, " PCHK2608140001#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); - assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608140001"); assertThat(resolved.cartonIndex()).isEqualTo(1); assertThat(resolved.totalCartons()).isEqualTo(1); assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); - verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); + verify(repository).findByBoxCode(10001L, "PCHK2608140001#1-1"); } @Test void exposesDetailedResolutionForFirstlegConsumers() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608140001#1-1"); assertThat(resolved.orderId()).isEqualTo(60001L); - assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608140001#1-1"); assertThat(resolved.cargoItemNo()).isEqualTo(1); } @@ -190,9 +190,9 @@ class InboundCartonLabelServiceTest { void rejectsInactiveBoxCodeDuringScanResolution() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); label.setBatchStatus("VOID"); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1")) + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608140001#1-1")) .isInstanceOf(BizException.class) .hasMessage("箱码已失效,请重新打印"); } @@ -233,7 +233,7 @@ class InboundCartonLabelServiceTest { @Test void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( - 10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer", + 10001L, 60001L, "FL60001", "PCHK2608140001", 1L, "Customer", 41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null, null, null, null, null, null, false, "FBA", null, null, null, null, null, null, 2); @@ -251,7 +251,7 @@ class InboundCartonLabelServiceTest { inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setOrderId(60001L); - inbound.setInboundCode("PCHK2608040174"); + inbound.setInboundCode("PCHK2608140001"); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setWarehouseName("MCC1"); inbound.setChannelName("洛杉矶普船海卡"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java deleted file mode 100644 index 04bb68275..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundFeeEntity; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - -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.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("发送费用并入库状态编排") -@SpringJUnitConfig(InboundCompletionOrchestrator.class) -class InboundCompletionOrchestratorTest { - @MockBean private FinanceFeeSourceOutboxService financeOutbox; - @MockBean - private InboundWorkflowRepository repository; - @MockBean - private InboundIdempotencyService idempotency; - @MockBean - private InboundFeeDetailService feeDetails; - @Resource - private InboundCompletionOrchestrator service; - @Test - @DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING") - void shouldBeginInboundAfterAllGuardsPass() { - Fixture fixture = fixture("APPROVED", false); - when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1); - - fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1"); - - verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4); - verify(fixture.repository).insertHistory(any()); - } - - @Test - @DisplayName("测量驳回且无异常放行记录时禁止发送费用") - void shouldBlockRejectedMeasurementWithoutRelease() { - Fixture fixture = fixture("REJECTED", true); - when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false); - - assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - @DisplayName("异常放行标记必须与费用快照一致") - void shouldRequireFeeSnapshotReleaseFlag() { - Fixture fixture = fixture("REJECTED", false); - when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true); - - assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) - .isInstanceOf(BizException.class) - .hasMessageContaining("异常放行标记"); - } - - @Test - @DisplayName("完成阶段原子发送费用、完成入库并写待投递事件") - void shouldCompleteInboundAndWriteOutbox() { - Fixture fixture = fixture("APPROVED", false); - fixture.order.setProcessStatus("INBOUNDING"); - when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1); - when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1); - com.baiyue.firstleg.entity.InboundFeeDetailEntity published = - new com.baiyue.firstleg.entity.InboundFeeDetailEntity(); - published.setSourceFeeVersion(1); - when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published); - - fixture.service.completeSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"); - - verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5); - verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L); - verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), - eq(10001L), eq(10L), eq(30L), eq(1), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - } - - @Test - @DisplayName("条件更新失败时报告并发冲突且不发送费用") - void shouldRejectConcurrentCompletion() { - Fixture fixture = fixture("APPROVED", false); - fixture.order.setProcessStatus("INBOUNDING"); - when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0); - - assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1")) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong()); - verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), - org.mockito.ArgumentMatchers.anyInt(), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - } - - @Test - @DisplayName("只有仓库预占已补偿后才通过显式方法回退") - void shouldRollbackOnlyInboundingState() { - Fixture fixture = fixture("APPROVED", false); - when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1); - - fixture.service.rollbackSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1"); - - verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5); - verify(fixture.repository).insertHistory(any()); - } - - private Fixture fixture(String reviewStatus, boolean feeRelease) { - when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); - InboundOrderEntity order = new InboundOrderEntity(); - order.setProcessStatus("FEE_CONFIRMING"); - order.setFeeStatus("CONFIRMED"); - order.setMeasureReviewStatus(reviewStatus); - InboundFeeEntity fee = new InboundFeeEntity(); - fee.setExceptionRelease(feeRelease); - when(repository.findOwned(10001L, 10L)).thenReturn(order); - when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee); - when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L); - return new Fixture(service, repository, order); - } - - private record Fixture(InboundCompletionOrchestrator service, - InboundWorkflowRepository repository, - InboundOrderEntity order) { - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java index f0c15d6ea..0a30da8b1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -56,7 +56,7 @@ class InboundMeasurementOpenApiServiceTest { {"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001", "externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01", "measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10, - "heightCm":30.00,"packageCount":5,"unitWeightKg":12.5, + "heightCm":30.00,"packageCount":5, "boxSpecification":"50.2x40.1x30cm"} """; private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 52634b9df..c2439be80 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.dto.InboundMeasurementResponses; @@ -25,8 +26,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.TestPropertySource; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; +import feign.FeignException; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; @@ -49,8 +53,19 @@ class InboundMeasurementServiceTest { @MockBean private MeasurementBatchService batchService; @MockBean private OrderCargoClient orderCargoClient; @MockBean private MeasurementImageClient imageClient; + @MockBean private WarehousePalletLocationClient warehousePallets; @Resource private InboundMeasurementService service; + @BeforeEach + void stubWarehousePalletSnapshots() { + when(repository.updateInboundWeightSummary(any())).thenReturn(true); + when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn( + ApiResponse.success(List.of(palletSnapshot()))); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); + } + @Test void customerCannotReadAnotherCustomersMeasurement() { assertThatThrownBy(() -> service.detail(2L, 10L)) @@ -85,6 +100,32 @@ class InboundMeasurementServiceTest { assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200"); } + @Test + void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("12.500")); + inbound.setActualNetWeightKg(new BigDecimal("12.500")); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); + weight.setGrossWeightKg(new BigDecimal("12.500")); + weight.setNetWeightKg(new BigDecimal("12.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); + + assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); + assertThat(weight.getGrossWeightKg()).isNull(); + assertThat(weight.getNetWeightKg()).isNull(); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); + verify(repository).updateInboundWeightSummary(inbound); + } + @Test void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); @@ -101,9 +142,75 @@ class InboundMeasurementServiceTest { assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); assertThat(result.inboundVersion()).isEqualTo(7); assertThat(result.version()).isEqualTo(4); + assertThat(result.originalDocumentNo()).isEqualTo("REF-50"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20"); + assertThat(result.actualGoodsName()).isEqualTo("123123"); verify(repository, never()).updateInbound(any()); } + @Test + void detailReportsOrderContractNotReadyInsteadOfExposingDownstreamBadRequest() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + FeignException downstreamBadRequest = mock(FeignException.class); + when(downstreamBadRequest.status()).thenReturn(400); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)) + .thenThrow(downstreamBadRequest); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOfSatisfying(BizException.class, exception -> { + assertThat(exception.getResultCode().code()).isEqualTo("S0503"); + assertThat(exception).hasMessageContaining("申报快照契约未就绪") + .hasMessageContaining("order-service") + .hasMessageContaining("firstleg-service"); + }); + } + + @Test + void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setVersion(4); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setTenantId(1L); + item.setMeasurementId(30L); + item.setSourceImageFileId(140L); + InboundWeightResultEntity weight = weight(50L, "30.000", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("30.000")); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + weight.setNetWeightKg(new BigDecimal("11.500")); + weight.setConfirmedAt(LocalDateTime.of(2026, 8, 17, 10, 30)); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500"); + assertThat(result.items()).singleElement().satisfies(dimension -> { + assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + }); + assertThat(result.weightResults()).singleElement().satisfies(confirmedWeight -> { + assertThat(confirmedWeight.id()).isEqualTo(50L); + assertThat(confirmedWeight.fileId()).isEqualTo(150L); + assertThat(confirmedWeight.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(confirmedWeight.confirmedAt()).isNotNull(); + }); + } + @Test void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -112,22 +219,56 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.WeightResult result = service.createWeightResult( 1L, 10L, 9L, new InboundMeasurementRequests.CreateWeightResultRequest( - 120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", + 120L, 501L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", new BigDecimal("0.5097191334"))); ArgumentCaptor saved = ArgumentCaptor.forClass(InboundWeightResultEntity.class); verify(repository).insertWeight(saved.capture()); assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972"); + assertThat(saved.getValue().getPalletId()).isEqualTo(501L); + assertThat(saved.getValue().getPalletCode()).isEqualTo("PLT-501"); + assertThat(saved.getValue().getPalletTareWeightKg()).isEqualByComparingTo("18.500"); assertThat(result.confidence()).isEqualByComparingTo("0.50972"); } + @Test + void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + List result = + service.weightPalletOptions(1L, 10L); + + assertThat(result).singleElement().satisfies(snapshot -> { + assertThat(snapshot.palletId()).isEqualTo(501L); + assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500"); + }); + verify(warehousePallets).palletSnapshots("internal-token", 100L); + } + + @Test + void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 999L, "FILE_WEIGHT_OCR", "20.0 KG", new BigDecimal("20.000"), + "KG", new BigDecimal("0.99")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于当前入库仓库"); + + verify(repository, never()).insertWeight(any()); + } + @Test void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); - previous.setFinalWeightKg(new BigDecimal("2.000")); + previous.setGrossWeightKg(new BigDecimal("2.000")); + previous.setNetWeightKg(new BigDecimal("2.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); when(repository.updateWeight(current)).thenReturn(true); @@ -138,12 +279,85 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } + @Test + void confirmingWeightAfterCartonMeasurementReplacesFallbackWithNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setActualGrossWeightKg(new BigDecimal("54.400")); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); + } + + @Test + void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0); + current.setPalletTareWeightKg(new BigDecimal("8.000")); + List weights = new java.util.ArrayList<>(); + weights.add(current); + for (long id = 21L; id <= 24L; id++) { + InboundWeightResultEntity confirmed = weight(id, "357.500", "KG", "CONFIRMED", 1); + confirmed.setGrossWeightKg(new BigDecimal("357.500")); + confirmed.setNetWeightKg(new BigDecimal("349.500")); + weights.add(confirmed); + } + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); + when(repository.updateWeight(current)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(weights); + + service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("1747.500"); + } + + @Test + void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "18.500", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("毛重必须大于托盘皮重"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + @ParameterizedTest @CsvSource({ "kg,2.000", @@ -168,7 +382,8 @@ class InboundMeasurementServiceTest { assertThat(response.adjustedWeightUnit()).isEqualTo( unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG"); - assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.grossWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.netWeightKg()).isEqualByComparingTo(expectedKg); assertThat(response.version()).isEqualTo(2); } @@ -186,7 +401,7 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001"); + assertThat(response.netWeightKg()).isEqualByComparingTo("0.001"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001"); } @@ -261,7 +476,8 @@ class InboundMeasurementServiceTest { 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG")); assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125"); - assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.netWeightKg()).isEqualByComparingTo("286.125"); assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); assertThat(response.version()).isEqualTo(2); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125"); @@ -322,7 +538,8 @@ class InboundMeasurementServiceTest { void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -341,7 +558,8 @@ class InboundMeasurementServiceTest { void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -390,7 +608,7 @@ class InboundMeasurementServiceTest { verify(repository).deleteWeight(1L, 10L, 20L, 1); verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); - verify(repository, never()).updateInboundWeightSummary(any()); + verify(repository).updateInboundWeightSummary(inbound); } @Test @@ -398,7 +616,8 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("285.500")); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -410,6 +629,7 @@ class InboundMeasurementServiceTest { service.deleteWeightResult(1L, 10L, 20L, 2, 9L); assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); verify(repository).updateInboundWeightSummary(inbound); } @@ -418,9 +638,11 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("15.000")); InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); - deleted.setFinalWeightKg(new BigDecimal("10.000")); + deleted.setGrossWeightKg(new BigDecimal("10.000")); + deleted.setNetWeightKg(new BigDecimal("10.000")); InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); - remaining.setFinalWeightKg(new BigDecimal("5.000")); + remaining.setGrossWeightKg(new BigDecimal("5.000")); + remaining.setNetWeightKg(new BigDecimal("5.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -432,6 +654,7 @@ class InboundMeasurementServiceTest { service.deleteWeightResult(1L, 10L, 20L, 2, 9L); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000"); verify(repository).updateInboundWeightSummary(inbound); } @@ -466,7 +689,8 @@ class InboundMeasurementServiceTest { item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); - weight.setFinalWeightKg(new BigDecimal("20.000")); + weight.setGrossWeightKg(new BigDecimal("20.000")); + weight.setNetWeightKg(new BigDecimal("20.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); @@ -482,6 +706,8 @@ class InboundMeasurementServiceTest { assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getPalletCount()).isEqualTo(1); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); } @@ -554,22 +780,48 @@ class InboundMeasurementServiceTest { when(batchService.saveBatch(any())).thenReturn(saved); InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, - new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), - new BigDecimal("3.250"), null, null, null, null, null, null)))); + null, null, null, null, null, null)))); assertThat(result.measurementId()).isEqualTo(30L); ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); verify(batchService).saveBatch(command.capture()); assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L); + assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50"); + assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20"); + assertThat(command.getValue().actualGoodsName()).isEqualTo("123123"); verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), null, null, null, null, null, null); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding( + 80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1")))); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256)))); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item)))) + .isInstanceOf(BizException.class).hasMessageContaining("超过255字符"); + verify(batchService, never()).saveBatch(any()); + } + private InboundOrderEntity inbound(String status) { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); inbound.setTenantId(1L); + inbound.setOrderId(80L); + inbound.setWarehouseId(100L); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); @@ -583,10 +835,21 @@ class InboundMeasurementServiceTest { weight.setTenantId(1L); weight.setInboundId(10L); weight.setFileId(id + 100L); + weight.setPalletId(501L); + weight.setPalletCode("PLT-501"); + weight.setPalletType("STANDARD"); + weight.setPalletCapacityCbm(new BigDecimal("2.0000")); + weight.setPalletTareWeightKg(BigDecimal.ZERO.setScale(3)); weight.setOcrWeightValue(new BigDecimal(value)); weight.setOcrWeightUnit(unit); weight.setRecognitionStatus(status); weight.setVersion(version); return weight; } + + private WarehousePalletLocationClient.PalletSnapshot palletSnapshot() { + return new WarehousePalletLocationClient.PalletSnapshot( + 501L, "PLT-501", "STANDARD", new BigDecimal("2.0000"), + new BigDecimal("18.500")); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index c69848b76..9f6c94123 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,18 +43,20 @@ class InboundOrderCreationServiceTest { @Mock private InboundStatusHistoryMapper histories; @Mock + private InboundPickupSnapshotMapper pickupSnapshots; + @Mock private FulfillmentTaskProvisioningService fulfillmentTasks; @InjectMocks private InboundOrderCreationService service; @Test void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { - OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77"); + OrderApprovedInboundCommand command = approvedCommand("PCHK2608140001"); InboundOrderEntity existing = new InboundOrderEntity(); existing.setId(900L); existing.setTenantId(10001L); existing.setOrderId(77L); - existing.setInboundCode("RK-CUSTOM-77"); + existing.setInboundCode("PCHK2608140001"); existing.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); @@ -64,7 +67,7 @@ class InboundOrderCreationServiceTest { ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); verify(orders, times(1)).insert(inboundCaptor.capture()); verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); - assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("PCHK2608140001"); assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L); assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); @@ -88,25 +91,25 @@ class InboundOrderCreationServiceTest { @Test void shouldConvergeOnConcurrentDuplicateInsertWinner() { - InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity winner = inbound(901L, "PCHK2608140001", "UNPRICED", null, null); winner.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) .when(orders).insert(any(InboundOrderEntity.class)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(901L); verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); } @Test void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() { - InboundOrderEntity existing = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity existing = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(900L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); assertThat(existing.getTaskId()).isEqualTo(700L); verify(orders).update(any(), any(Wrapper.class)); @@ -115,14 +118,14 @@ class InboundOrderCreationServiceTest { @Test void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() { - InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); prepared.setProcessStatus("PREPARING"); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77")); - long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77")); + long preparedId = service.prepareFromDraft(approvedCommand("PCHK2608140001")); + long approvedId = service.createFromApprovedOrder(approvedCommand("PCHK2608140001")); assertThat(preparedId).isNotNull(); assertThat(approvedId).isEqualTo(900L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java new file mode 100644 index 000000000..24353fb70 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InitialInboundPricingServiceTest { + private final FbaPricingService pricing = mock(FbaPricingService.class); + private final InitialInboundPricingService service = new InitialInboundPricingService(); + + InitialInboundPricingServiceTest() { + ReflectionTestUtils.setField(service, "pricing", pricing); + } + + @Test + void returnsCalculatedDraftWithoutChangingWaitSplit() { + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(fee.feeStatus()).thenReturn("CALCULATED"); + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")).thenReturn(fee); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("CALCULATED"); + assertThat(result.initialPricingSucceeded()).isTrue(); + assertThat(result.initialFee()).isSameAs(fee); + } + + @Test + void keepsWaitSplitWhenNoPriceMatches() { + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")) + .thenThrow(new BizException("未匹配有效价格")); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("UNPRICED"); + assertThat(result.initialPricingSucceeded()).isFalse(); + assertThat(result.initialPricingMessage()).contains("未匹配有效价格"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 0f3116491..959518478 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -46,7 +47,16 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); - assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); + assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); + assertThat(first.eventVersion()).isEqualTo(2); + } + + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { + String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("p.plan_status = 'COMPLETED'") + .contains("o.process_status = 'LOADED'"); } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 071387fa7..aac76e2e6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -6,6 +6,9 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.client.LoadingDocumentFileClient; @@ -13,6 +16,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -31,6 +35,7 @@ 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.anyString; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -45,6 +50,8 @@ class LoadingListServiceTest { @MockBean private LoadingPickRepository pickRepository; @MockBean + private LoadingPickService loadingPickService; + @MockBean private LoadingDocumentFileClient documentFiles; @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @@ -61,7 +68,7 @@ class LoadingListServiceTest { @Test void shouldAcceptEditingStatusFromPreloadList() { - PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20); + PreloadQuery query = new PreloadQuery(null, "EDITING", 200L, 100L, null, null, 1, 20); service.preloadPage(query); @@ -87,11 +94,43 @@ class LoadingListServiceTest { new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), eq("补录箱号")); } + @Test + void pushedEditingPreloadCanReceiveContainerNumber() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + after.setContainerNo("CSLU1234567"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", null, 9L, "operator")).thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")); + + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void editingPlanCannotReceiveTransportBeforePush() { + when(repository.aggregate(10L)).thenReturn(row("EDITING", 3)); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"))) + .isInstanceOf(BizException.class).hasMessageContaining("已推送"); + + verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), + org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void shouldRejectTransportUpdateAfterConfirmation() { when(repository.history(anyString())).thenReturn(null); @@ -137,6 +176,10 @@ class LoadingListServiceTest { verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), eq("PDA已人工确认装柜完成")); + verify(loadingCompletedOutboxService, never()).publish(10L); + + service.publishCompletedEvent(10L); + verify(loadingCompletedOutboxService).publish(10L); } @@ -184,14 +227,14 @@ class LoadingListServiceTest { @Test void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { - ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20); + ConfirmedQuery query = new ConfirmedQuery(" plan ", 200L, 100L, null, null, 1, 20); when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); service.confirmedPage(query); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> - "plan".equals(value.keyword()))); + "plan".equals(value.keyword()) && Long.valueOf(200L).equals(value.customerId()))); } @Test @@ -201,8 +244,8 @@ class LoadingListServiceTest { when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); - service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, 1, 20)); - service.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)); + service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, null, 1, 20)); + service.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)); verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); @@ -456,10 +499,158 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + List links = List.of( + new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null), + new Link(2L, 102L, 202L, 302L, "CONFIRMING", 5, 10L, 30L, + "MSCU-001", "EDITING", 3, 6, null)); + PreloadReadiness readiness = readiness("CONFIRMED", false, true, 80L); + var detail = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail.class); + var summary = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary.class); + when(detail.summary()).thenReturn(summary); + when(summary.readiness()).thenReturn(readiness); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); + when(repository.preloadDetail(after)).thenReturn(detail); + + PreloadConfirmationResponse response = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(4); + assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); + verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); + verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), + eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), + eq("operator"), org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String requestHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + ObjectMapper mapper = new ObjectMapper(); + replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString( + java.util.Map.of("response", expected, "requestHash", requestHash))); + when(repository.history(anyString())).thenReturn(replay); + + PreloadConfirmationResponse actual = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(actual).isEqualTo(expected); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String originalHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( + java.util.Map.of("requestHash", originalHash, "response", expected))); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + @Test + void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { + AggregateRow confirmed = row("EDITING", 4); + confirmed.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(confirmed); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("第二票"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void reusedConfirmationKeyWithDifferentVersionConflictsBeforeReadiness() { + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, + boolean readyForPda, Long taskId) { + return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId, + taskId == null ? null : "LOADING", confirmationStatus, canConfirm, + readyForPda, List.of()); + } + private static AggregateRow row(String status, int version) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); row.setPlanNo("PL-10"); + row.setContainerBookingId(30L); row.setPlanStatus(status); row.setWarehouseId(100L); row.setVersion(version); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 73251d400..dbaefc6b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -17,10 +17,22 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.service.LoadingDeliveryService; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.InOrder; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -31,7 +43,11 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -46,57 +62,244 @@ class LoadingPickServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean + private OceanBookingRepository oceanBookingRepository; + @MockBean + private LoadingModelBridgeService loadingModelBridge; + @MockBean private LoadingListService loadingListService; @MockBean + private LoadingDeliveryService loadingDeliveryService; + @MockBean + private TruckAppointmentService truckAppointmentService; + @MockBean + private InboundStatusHistoryMapper inboundHistories; + @MockBean private PdaPickSettlementService settlementService; @MockBean private WarehousePalletLocationClient warehouse; @Resource private LoadingPickService service; + @BeforeEach + void activeOperationalContainer() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setContainerNo("CSLU1234567"); + booking.setSourceType("SIMULATION"); + booking.setRecordStatus("ACTIVE"); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + } + + @Test + void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(smartLoadingRepository.plan(10L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(null); + when(smartLoadingRepository.items(10L)).thenReturn(List.of()); + doAnswer(invocation -> { + invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L); + return null; + }).when(repository).insert(any(LoadingPickTaskEntity.class)); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository).insert(any(LoadingPickTaskEntity.class)); + } + + @Test + void doesNotOpenTaskBeforeOceanRelease() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + } + + @Test + void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(oceanBookingRepository, never()).allocationByContainer(40L); + } + + @Test + void planConfirmationGateRequiresRealContainerAndReleasedBooking() { + Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, + 10L, 40L, "MSCU-001", "EDITING", 3, 2, null); + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); + ocean.setId(90L); ocean.setStatus("RELEASED"); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); + } + + @Test + void planConfirmationGateRejectsMissingRealContainerNumber() { + Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, + 10L, 40L, null, "EDITING", 3, 2, null); + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号"); + verify(oceanBookingRepository, never()).allocationByContainer(anyLong()); + } + + @Test + void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() { + Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2, + 10L, 40L, "MSCU-001", "EDITING", 4, 2, null); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)) + .thenReturn(List.of(confirmed)); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("已确认"); + verify(smartLoadingRepository, never()).booking(anyLong()); + } + + @Test + void readinessCreatesAtMostOneTask() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity()); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(smartLoadingRepository, never()).startPicking( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString()); + } + @Test void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() { LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); booking.setContainerNo("MSCU-NEW"); + booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task); when(repository.taskByPlan(10L, 101L)).thenReturn(task); when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(repository.activeBookingByTask(20L)).thenReturn(booking); when(repository.items(20L, 101L)).thenReturn(List.of()); when(repository.cartonLabels(20L)).thenReturn(List.of()); - var byCabinet = service.task(null, "CAB-001", null, 101L); - var byPlan = service.task(null, null, 10L, 101L); + var byCabinet = service.task("CAB-001", null, 101L); + var byPlan = service.task(null, 10L, 101L); assertThat(byCabinet.id()).isEqualTo(20L); assertThat(byPlan.id()).isEqualTo(20L); assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW"); + assertThat(byCabinet.sourceType()).isEqualTo("SIMULATION"); + assertThat(byCabinet.recordStatus()).isEqualTo("ACTIVE"); + assertThat(byCabinet.mergedIntoContainerBookingId()).isNull(); + } + + @Test + void pendingTasksExposeServerFilteredContainerLifecycle() { + var row = new com.baiyue.firstleg.mapper.LoadingPickTaskMapper.PendingTaskRow(); + row.setTaskId(20L); + row.setLoadingPlanId(10L); + row.setContainerBookingId(99L); + row.setCabinetNo("CAB-001"); + row.setContainerNo("MSCU-001"); + row.setSourceType("SIMULATION"); + row.setRecordStatus("ACTIVE"); + row.setMergedIntoContainerBookingId(null); + row.setWarehouseId(101L); + row.setTaskStatus("LOADING"); + row.setWorkflowStatus("AWAITING_REVIEW"); + row.setVersion(0); + row.setAssignmentVersion(0); + row.setItemCount(1); + row.setPendingItemCount(0); + when(repository.pendingTasks(9L, null)).thenReturn(List.of(row)); + + var result = service.pendingTasks(9L, null).get(0); + + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); + assertThat(result.workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(result.pendingItemCount()).isZero(); + } + + @Test + void taskDetailRejectsMergedContainerSource() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.activeBookingByTask(20L)).thenReturn(null); + when(repository.items(20L, 101L)).thenReturn(List.of()); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.task(20L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("已非有效存续记录"); } @Test void rejectsMissingOrConflictingCompatibilitySelectors() { - assertThatThrownBy(() -> service.task(null, null, null, 101L)) + assertThatThrownBy(() -> service.task(null, null, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); - assertThatThrownBy(() -> service.task("MSCU", "CAB", null, 101L)) + assertThatThrownBy(() -> service.task("CAB", 10L, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); } - @Test - void legacyContainerLookupReturnsCurrentTransportDisplayWithoutChangingIdentity() { - LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); - var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-CURRENT"); - when(repository.taskByContainer("MSCU-CURRENT", 101L)).thenReturn(task); - when(smartLoadingRepository.booking(40L)).thenReturn(booking); - when(repository.items(20L, 101L)).thenReturn(List.of()); - when(repository.cartonLabels(20L)).thenReturn(List.of()); - var result = service.task("MSCU-CURRENT", null, null, 101L); - assertThat(result.id()).isEqualTo(20L); - assertThat(result.loadingPlanId()).isEqualTo(10L); - assertThat(result.currentContainerNo()).isEqualTo("MSCU-CURRENT"); - } - @Test void keepsPlanLoadingWhenItemsRemain() { LoadingPickTaskEntity task = task(); @@ -143,6 +346,76 @@ class LoadingPickServiceTest { verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); } + @Test + void startsInboundLoadingEvenWhenPlanWasAlreadyStarted() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-start-inbound"); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(0); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1); + + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories).insert(argThat((InboundStatusHistoryEntity history) -> + Long.valueOf(101L).equals(history.getTenantId()) + && Long.valueOf(50L).equals(history.getInboundId()) + && "WAIT_SPLIT".equals(history.getFromStatus()) + && "LOADING".equals(history.getToStatus()) + && "PDA_START_LOADING".equals(history.getAction()))); + verify(smartLoadingRepository, never()).insert(any( + com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); + } + + @Test + void acceptsConcurrentInboundTransitionWithoutDuplicateHistory() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-concurrent-inbound"); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4), + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0); + + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories, never()).insert(any(InboundStatusHistoryEntity.class)); + } + + @Test + void taskDetailReturnsServerInboundAndLoadingVersions() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.assignment(20L)).thenReturn(null); + when(repository.review(20L)).thenReturn(null); + when(repository.allTaskItemsResolved(20L)).thenReturn(false); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + var result = service.task(20L, 101L); + + assertThat(result.inboundId()).isEqualTo(50L); + assertThat(result.inboundVersion()).isEqualTo(7); + assertThat(result.loadingVersion()).isEqualTo(7); + assertThat(result.loadingPlanId()).isEqualTo(10L); + assertThat(result.containerBookingId()).isEqualTo(99L); + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); + } + @Test void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() { LoadingPickTaskEntity task = task(); @@ -215,12 +488,18 @@ class LoadingPickServiceTest { } @Test - void manualCompletionSettlesResolvedItemsAndCompletesPlan() { + void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() { LoadingPickTaskEntity task = task(); LoadingPickItemEntity item = item(); + AtomicReference operation = new AtomicReference<>(); item.setPlannedPackageCount(5); item.setPickedPackageCount(3); item.setItemStatus("PARTIAL"); + when(repository.operation("complete-1")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { + operation.set(invocation.getArgument(0)); + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task); when(repository.items(20L)).thenReturn(List.of(item)); @@ -228,13 +507,82 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); + service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); verify(repository).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-1-50", "checked"); + verify(loadingListService).publishCompletedEvent(10L); + InOrder finalization = inOrder(loadingListService, loadingDeliveryService); + finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-1-50", "checked"); + finalization.verify(loadingListService).publishCompletedEvent(10L); + } + + @Test + void conflictingTruckAppointmentsDoNotBlockPdaCompletion() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.review(20L)).thenReturn(null); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "同一柜次存在多条有效拖车预约,跳过自动完成")) + .when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); + + service.complete(20L, + new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator"); + + verify(settlementService).settle(20L, 9L, "operator"); + verify(repository).approve(20L, 9L, "operator", "checked"); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-not-blocked-50", "checked"); + verify(loadingListService).publishCompletedEvent(10L); + } + + @Test + void doesNotPublishCompletedEventWhenInboundFinalizationFails() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "入库单并发修改")) + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-finalize-fails-50", "checked"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("并发修改"); + + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); } @Test @@ -271,7 +619,7 @@ class LoadingPickServiceTest { ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); @@ -301,7 +649,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); @@ -325,7 +673,7 @@ class LoadingPickServiceTest { when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) @@ -343,7 +691,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) @@ -373,14 +721,14 @@ class LoadingPickServiceTest { 30L, "PICK", 2, null, "MANUAL", List.of(), 0, null); assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 1, snapshotToken, List.of(line)), 9L, "operator")) + 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("任务版本已变化"); assertThat(item.getVersion()).isZero(); assertThat(item.getPickedPackageCount()).isZero(); service.batch(20L, "batch-valid", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(line)), 9L, "operator"); + 101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator"); verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); verify(repository, times(1)).touchTask(20L, 0); @@ -401,7 +749,7 @@ class LoadingPickServiceTest { service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem)); - BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 1, + BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 80adff85a..8116e890d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -70,12 +70,11 @@ class MeasurementBatchServiceTest { assertThat(items.getValue().get(0).getCalculationRuleVersion()) .isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0"); assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000"); - assertThat(items.getValue().get(0).getUnitWeightKg()).isNull(); verify(repository, never()).updateInbound(any()); } @Test - void manualDraftCalculatesWeightVolumeAndVolumeWeightOnServer() { + void manualDraftCalculatesVolumeAndVolumeWeightOnServer() { MeasurementBatchCommand command = new MeasurementBatchCommand( 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"), @@ -90,7 +89,6 @@ class MeasurementBatchServiceTest { verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); - assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500"); } @Test @@ -143,7 +141,7 @@ class MeasurementBatchServiceTest { private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { return new MeasurementBatchCommand.Item( 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), - new BigDecimal("20"), unitWeight, 500L, null, + new BigDecimal("20"), 500L, null, new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index a355c4eb9..7e8d334d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -1,9 +1,12 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository; import jakarta.annotation.Resource; @@ -21,6 +24,7 @@ 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.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,6 +43,7 @@ class MeasurementLocationOccupationServiceTest { @MockBean private WarehousePalletLocationClient warehouse; @MockBean private InboundPalletAllocationRepository allocations; @MockBean private InboundWorkflowRepository workflow; + @MockBean private InboundMeasurementRepository measurements; @Resource private InboundProperties properties; @Resource private MeasurementLocationOccupationService service; @@ -46,6 +51,7 @@ class MeasurementLocationOccupationServiceTest { void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() { properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), @@ -59,6 +65,9 @@ class MeasurementLocationOccupationServiceTest { assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND); assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-"); + assertThat(reserve.getValue().pallets()) + .extracting(WarehousePalletLocationClient.PalletReference::palletId) + .containsExactly(601L, 601L, 601L); ArgumentCaptor confirm = ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); @@ -72,7 +81,6 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), - org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } @@ -81,6 +89,7 @@ class MeasurementLocationOccupationServiceTest { void defaultResourceTenantRemainsPlatformZero() { properties.setWarehouseResourceTenantId(0L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), @@ -104,15 +113,57 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), - org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } + @Test + void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setConfirmedAt(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少确认时间"); + + verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsConfirmedWeightWithoutNetWeightInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setNetWeightKg(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少有效净重"); + + verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsConfirmedWeightWithoutPalletInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setPalletId(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少有效托盘"); + + verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + @Test void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() { properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); IllegalStateException failure = new IllegalStateException("confirm failed"); @@ -130,7 +181,7 @@ class MeasurementLocationOccupationServiceTest { assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(release.getValue().businessId()).isEqualTo(INBOUND); verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, - new BigDecimal("1.200000"), 20, batch("RESERVED")); + 20, batch("RESERVED")); } private static InboundProperties properties() { @@ -159,9 +210,22 @@ class MeasurementLocationOccupationServiceTest { } private static WarehousePalletLocationClient.BatchResult batch(String state) { - return new WarehousePalletLocationClient.BatchResult(1, List.of( - new WarehousePalletLocationClient.AllocationResult( - 501L, "RES-001", 1, 601L, "PL-001", 701L, "A-001", - new BigDecimal("1.0000"), state))); + return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3) + .mapToObj(index -> new WarehousePalletLocationClient.AllocationResult( + 500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"), + 700L + index, "A-00" + index, new BigDecimal("0.333333"), state)) + .toList()); + } + + private static List confirmedWeights() { + return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setId(800L + index); + weight.setPalletId(601L); + weight.setRecognitionStatus("CONFIRMED"); + weight.setConfirmedAt(java.time.LocalDateTime.now()); + weight.setNetWeightKg(new BigDecimal("10")); + return weight; + }).toList(); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index dbe2ce599..54792c723 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; @@ -41,6 +43,7 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(MeasurementReviewService.class) class MeasurementReviewServiceTest { @MockBean private InboundWorkflowRepository repository; + @MockBean private InboundMeasurementRepository measurements; @MockBean private InboundIdempotencyService idempotency; @MockBean private InboundProperties properties; @Resource private MeasurementReviewService service; @@ -78,7 +81,24 @@ class MeasurementReviewServiceTest { ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); verify(fixture.repository).insertHistory(history.capture()); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); - assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING"); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + @DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量") + void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(5); + item.setTotalVolumeCbm(new BigDecimal("0.7480")); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-authoritative-weight", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L); } @Test @@ -195,20 +215,35 @@ class MeasurementReviewServiceTest { order.setProcessStatus("MEASURE_PENDING"); order.setMeasureReviewStatus("PENDING"); order.setTotalPackageCount(5); + order.setActualGrossWeightKg(new BigDecimal("1787.500")); + order.setActualNetWeightKg(new BigDecimal("1747.500")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(20L); measurement.setStatus("SUBMITTED"); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); item.setPackageCount(5); - item.setTotalWeightKg(new BigDecimal("54.400")); item.setTotalVolumeCbm(new BigDecimal("0.7480")); item.setVolumeWeightKg(new BigDecimal("124.916")); when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of( + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"))); return new Fixture(service, repository, idempotency, order, measurement); } + private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setRecognitionStatus("CONFIRMED"); + weight.setGrossWeightKg(new BigDecimal(grossWeightKg)); + weight.setNetWeightKg(new BigDecimal(netWeightKg)); + return weight; + } + private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, InboundIdempotencyService idempotency, InboundOrderEntity order, InboundMeasurementEntity measurement) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index ad4d849dd..9412f0928 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -41,6 +42,46 @@ class MerchantPriceBookServiceTest { @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; + @Test + void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("MATSON_CLX_TRUCK")) + .thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK")); + + assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA", true) + .inboundCodePrefix()).isEqualTo("ZBYHK"); + assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS", true) + .inboundCodePrefix()).isEqualTo("ZBYHWK"); + } + + @Test + void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("OA_HOUSTON_TRUCK")) + .thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null)); + + assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS", true) + .inboundCodePrefix()).isEqualTo("HPHK"); + } + + @Test + void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); + var response = service.requireAvailableChannel( + 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); + + assertThat(response.channelCode()).isEqualTo("LEGACY_CHANNEL"); + assertThat(response.inboundCodePrefix()).isNull(); + verify(repository, never()).channelPrefix("LEGACY_CHANNEL"); + } + @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { when(repository.activeVersionId()).thenReturn(null); @@ -187,6 +228,26 @@ class MerchantPriceBookServiceTest { verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); } + @Test + void shouldResolveTierRegionFromPlatformOverseasWarehousePostalCode() { + stubChannel(8L, "MATSON_MAX_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(901L, 0L, "US-LAX-01", "洛杉矶仓", + "OVERSEAS", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "CA", "Los Angeles", null, + "90001", "address", null); + when(warehouses.active("internal-token", 0L, 901L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_MAX_PARCEL", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + verify(repository).regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED"); + } + @Test void shouldRejectMissingRequiredEstimateFields() { stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); @@ -206,7 +267,7 @@ class MerchantPriceBookServiceTest { request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, PricingTaxMode.TAX_INCLUDED, "51", null))) .isInstanceOf(BizException.class) - .hasMessageContaining("目的仓区域或平台FBA仓"); + .hasMessageContaining("目的仓区域"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index bb99f3f61..3c3415160 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -50,7 +50,7 @@ class MojiaMeasurementOpenApiServiceTest { } @Test - void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception { + void generatedDailyInboundCartonCodeFlowsThroughMojiaMeasurement() throws Exception { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); CartonMeasurementService cartons = mock(CartonMeasurementService.class); MeasurementImageClient images = mock(MeasurementImageClient.class); @@ -62,17 +62,17 @@ class MojiaMeasurementOpenApiServiceTest { when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult( - 1L, 1, 2L, "RK-001#4-1", "FBA19L5KMY2CU000002", 1, + 1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), - new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true)); + new BigDecimal("30.3"), 3L, "MJ-01", LocalDateTime.now(), true)); UploadResponse result = service.receive(headers, json()); assertThat(result.matched()).isTrue(); - assertThat(result.boxCode()).isEqualTo("RK-001#4-1"); + assertThat(result.boxCode()).isEqualTo("PCHK2608140001#5-1"); ArgumentCaptor command = ArgumentCaptor.forClass(CartonMeasurementCommand.class); verify(cartons).upsert(eq(10001L), eq(90001L), command.capture()); - assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1"); + assertThat(command.getValue().boxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1"); assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg"); @@ -108,7 +108,7 @@ class MojiaMeasurementOpenApiServiceTest { ArgumentCaptor receipt = ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); verify(receipts).insert(receipt.capture()); - assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1"); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(receipt.getValue().getScannedShipmentId()).isNull(); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); @@ -117,9 +117,9 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ - {"boxCode":"RK-001#4-1","shipment_id":"FBA19L5KMY2CU000002", + {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", "length":60.1,"width":40.2,"height":30.3, - "weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} + "machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index ea790c269..a1974d345 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -5,16 +5,20 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.split.service.SplitPreloadService; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; import jakarta.annotation.Resource; @@ -24,13 +28,17 @@ import org.mockito.ArgumentCaptor; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.sql.SQLException; import java.util.HexFormat; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -41,6 +49,8 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -51,6 +61,9 @@ class SmartLoadingServiceTest { @MockBean private SmartLoadingRepository repository; @MockBean private OrderCargoClient cargoClient; @MockBean private LoadingPickService loadingPickService; + @MockBean private LoadingModelBridgeService loadingModelBridge; + @MockBean private SplitPreloadService splitPreloadService; + @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @Resource private SmartLoadingService service; @TestConfiguration @@ -61,6 +74,9 @@ class SmartLoadingServiceTest { @BeforeEach void setUp() { + when(documentNumbers.next("LP")).thenReturn("LP-260818-0001"); + when(loadingPickService.mutationSnapshot(anyLong())).thenReturn( + new LoadingPickService.MutationSnapshot(null, null, null, Map.of())); when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); @@ -88,6 +104,53 @@ class SmartLoadingServiceTest { assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING"); } + @Test + void exposesPickedMinimumAndItemCapabilitiesForRunningTask() { + LoadingPlanEntity plan = plan("LOADING", 3); + LoadingPlanItemEntity line = item(8, 9, 20, 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(line)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2))); + + Plan result = service.detail(1L); + + assertThat(result.canAssign()).isTrue(); + assertThat(result.canEdit()).isTrue(); + assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS"); + assertThat(result.pdaTaskId()).isEqualTo(77L); + assertThat(result.pdaTaskVersion()).isEqualTo(6); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.getPickedPackageCount()).isEqualTo(2); + assertThat(item.getMinPackageCount()).isEqualTo(2); + assertThat(item.getCanAdjust()).isTrue(); + assertThat(item.getCanRemove()).isFalse(); + }); + } + + @Test + void completedTaskDisablesEveryIncrementalCapability() { + LoadingPlanEntity plan = plan("COMPLETED", 4); + LoadingPlanItemEntity line = item(8, 9, 20, 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(line)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3))); + + Plan result = service.detail(1L); + + assertThat(result.canAssign()).isFalse(); + assertThat(result.canEdit()).isFalse(); + assertThat(result.blockedReason()).contains("最终完成"); + assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED"); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.getCanAdjust()).isFalse(); + assertThat(item.getCanRemove()).isFalse(); + }); + } + @Test void doesNotRestoreCompletedPlanInTheLoadingWorkbench() { when(repository.booking(99L)).thenReturn(booking()); @@ -113,6 +176,22 @@ class SmartLoadingServiceTest { verify(repository, never()).insert(any(LoadingPlanEntity.class)); } + @Test + void createsPlanWithShortStableNumber() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(null); + doAnswer(invocation -> { + LoadingPlanEntity value = invocation.getArgument(0); + value.setId(1L); + return 1; + }).when(repository).insert(any(LoadingPlanEntity.class)); + when(repository.items(1L)).thenReturn(List.of()); + + Plan result = service.create(99L, "备注", 7L, "u"); + + assertThat(result.planNo()).isEqualTo("LP-260818-0001"); + } + @Test void rejectsExcessBoxes() { LoadingPlanEntity plan = plan("EDITING", 0); @@ -128,6 +207,22 @@ class SmartLoadingServiceTest { verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); } + @Test + void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() { + LoadingPlanEntity plan = plan("EDITING", 0); + ContainerBookingEntity linked = booking(); + linked.setCustomerId(88L); + when(repository.plan(1L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(linked); + when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5)); + + BizException error = catchThrowableOfType(() -> service.assign(1L, + new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + @Test void allowsVolumeAndWeightWithinOneHundredTenPercent() { stubAssignment(); @@ -138,6 +233,7 @@ class SmartLoadingServiceTest { service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u"); + verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); verify(repository).touch(1, 0, 7, "u"); } @@ -163,7 +259,7 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", - 4, 100, new BigDecimal("10"), "货物备注"))); + 4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注"))); doAnswer(invocation -> { Object value = invocation.getArgument(0); if (value instanceof LoadingPlanItemEntity item) item.setId(88L); @@ -180,8 +276,10 @@ class SmartLoadingServiceTest { assertThat(saved.getProductNameEn()).isEqualTo("English name"); assertThat(saved.getUnitsPerCarton()).isEqualTo(4); assertThat(saved.getProductQuantity()).isEqualTo(20); - assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5"); - assertThat(saved.getInternalRemark()).isEqualTo("货物备注"); + assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5"); + assertThat(saved.getCustomerRemark()).isEqualTo("客户备注"); + assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注"); + assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注"); ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); verify(repository).insert(historyCaptor.capture()); @@ -203,13 +301,12 @@ class SmartLoadingServiceTest { } @Test - void recyclesLegacyLoadingPlanBeforeAddingFirstItem() { - LoadingPlanEntity stale = plan("LOADING", 5); - LoadingPlanEntity after = plan("EDITING", 7); - when(repository.plan(1)).thenReturn(stale, after); + void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() { + LoadingPlanEntity loading = plan("LOADING", 5); + LoadingPlanEntity after = plan("LOADING", 6); + when(repository.plan(1)).thenReturn(loading, after); when(repository.booking(99)).thenReturn(booking()); - when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1); - when(repository.touch(1, 6, 7, "u")).thenReturn(1); + when(repository.touch(1, 5, 7, "u")).thenReturn(1); when(repository.items(1)).thenReturn(List.of()); when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); @@ -217,11 +314,15 @@ class SmartLoadingServiceTest { new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1, BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null))); - Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u"); + Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); - assertThat(result.status()).isEqualTo("EDITING"); - verify(repository).recycleUnpicked(1, 5, 7, "u"); - verify(repository).touch(1, 6, 7, "u"); + assertThat(result.status()).isEqualTo("LOADING"); + var order = inOrder(loadingPickService, repository, loadingModelBridge); + order.verify(loadingPickService).lockMutable(1L); + order.verify(repository).touch(1, 5, 7, "u"); + order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, + 7L, "u", "assign-loading"); + order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class)); } @Test @@ -301,6 +402,154 @@ class SmartLoadingServiceTest { verify(repository).touch(1, 2, 7, "u"); } + @Test + void updatesAndClearsBlankWarehouseRemarkWithBothVersions() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("旧仓库备注"); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1); + + Plan result = service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(loadingPickService).lockMutable(1L); + var order = inOrder(loadingPickService, repository); + order.verify(loadingPickService).lockMutable(1L); + order.verify(repository).touch(1L, 2, 7L, "u"); + order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u"); + ArgumentCaptor history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(history.capture()); + assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK"); + assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注"); + } + + @Test + void updatesWarehouseRemarkWhilePlanIsEditing() { + LoadingPlanEntity plan = plan("EDITING", 1); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan, plan("EDITING", 2)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 1, 7L, "u")).thenReturn(1); + when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1); + + Plan result = service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u"); + + assertThat(result.status()).isEqualTo("EDITING"); + assertThat(result.version()).isEqualTo(2); + verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u"); + } + + @Test + void warehouseRemarkConflictProducesNoHistory() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0); + + BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository).touch(1L, 2, 7L, "u"); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void warehouseRemarkMapsTriggerSqlStateToConflict() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")) + .thenThrow(new DataIntegrityViolationException("trigger", + new SQLException("状态冲突", "45000"))); + + BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试"); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() { + when(repository.plan(1L)).thenReturn(plan("LOADING", 3)); + + BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(), + any(), anyLong(), anyString()); + } + + @Test + void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() { + when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4)); + + BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(loadingPickService, never()).lockMutable(anyLong()); + verify(repository, never()).item(anyLong(), anyLong()); + verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(), + any(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void exactWarehouseRemarkReplayIsReadOnly() { + String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0"); + when(repository.operation("warehouse-replay")) + .thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash)); + when(repository.plan(1L)).thenReturn(plan("LOADING", 3)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1))); + + Plan result = service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(loadingPickService, never()).lockMutable(anyLong()); + verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(), + any(), anyLong(), anyString()); + } + + @Test + void quantityAdjustmentPreservesWarehouseRemark() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("装柜时注意外箱"); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.update(line)).thenReturn(1); + + service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); + } + @Test void preservesDownstreamCode() { stubAssignment(); @@ -368,13 +617,135 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "key", 3, 7, "u"); + ArgumentCaptor history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(history.capture()); + assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD"); + assertThat(history.getValue().getAfterSnapshot()) + .contains(sha256("PUSH_PRELOAD|1|3")); + var ordered = inOrder(splitPreloadService, loadingModelBridge); + ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); + ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test - void rejectsOverweightPlanBeforePushEvenWithoutContainerNumber() { + void exactPushReplayIsReadOnly() { + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(pushed); + when(repository.booking(99)).thenReturn(booking()); + when(repository.operation("key")) + .thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3"))); + + Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u"); + + assertThat(replay.version()).isEqualTo(4); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + } + + @Test + void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() { + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(pushed); + when(repository.booking(99)).thenReturn(booking()); + when(repository.operation("key")) + .thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3"))); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("不同推送请求"); + verify(repository, never()).items(anyLong()); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + verifyNoPushSideEffects(); + } + + @Test + void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() { + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("first-key"); + when(repository.plan(1)).thenReturn(pushed); + when(repository.booking(99)).thenReturn(booking()); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("新的幂等键"); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verifyNoPushSideEffects(); + } + + @Test + void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() { + LoadingPlanEntity editing = plan("EDITING", 3); + when(repository.plan(1)).thenReturn(editing); + when(repository.booking(99)).thenReturn(booking()); + when(repository.operation("used-key")) + .thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0"))); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("不同推送请求"); + verify(repository, never()).items(anyLong()); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + verifyNoPushSideEffects(); + } + + @Test + void bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback() { + LoadingPlanEntity editing = plan("EDITING", 3); + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + when(repository.push(1, "key", 3, 7, "u")).thenReturn(1); + doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在")) + .when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class); + + assertThat(error.getMessage()).contains("预装计划不存在"); + verify(repository).push(1, "key", 3, 7, "u"); + verify(repository).insert(any(LoadingPlanHistoryEntity.class)); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void concurrentPushKeyClaimReturnsConflictBeforeBridgeAndPda() { + LoadingPlanEntity editing = plan("EDITING", 3); + when(repository.plan(1)).thenReturn(editing); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + when(repository.push(1, "race-key", 3, 7, "u")).thenReturn(1); + doThrow(new DuplicateKeyException("duplicate operation_id")) + .when(repository).insert(any(LoadingPlanHistoryEntity.class)); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "race-key", new PushRequest(3), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("不同推送请求"); + verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void rejectsOverweightPlanBeforePush() { LoadingPlanEntity editing = plan("EDITING", 1); ContainerBookingEntity booking = booking(); - booking.setContainerNo(null); booking.setMaxWeightKg(new BigDecimal("1")); LoadingPlanItemEntity overweight = item(8, 9, 20, 1); overweight.setGrossWeightKg(new BigDecimal("2")); @@ -389,25 +760,46 @@ class SmartLoadingServiceTest { assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getMessage()).contains("重量").contains("110%"); verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test - void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() { + void pushesWithoutContainerNumberAndDoesNotOpenPda() { LoadingPlanEntity editing = plan("EDITING", 1); - LoadingPlanEntity pushed = plan("EDITING", 2); ContainerBookingEntity booking = booking(); booking.setContainerNo(null); - when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2)); when(repository.booking(99)).thenReturn(booking); when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1); - Plan result = service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"); + service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"); - assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "no-container-key", 1, 7, "u"); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() { + LoadingPlanEntity pushed = plan("EDITING", 2); + pushed.setPushIdempotencyKey("replay-key"); + ContainerBookingEntity booking = booking(); + booking.setContainerNo(" "); + when(repository.plan(1)).thenReturn(pushed); + when(repository.booking(99)).thenReturn(booking); + when(repository.operation("replay-key")) + .thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2"))); + + service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"); + + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + private void verifyNoPushSideEffects() { + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } private void stubAssignment() { @@ -449,14 +841,14 @@ class SmartLoadingServiceTest { private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { LoadingPlanItemEntity value = new LoadingPlanItemEntity(); value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); - value.setInboundDetailId(detail); value.setPackageCount(boxes); + value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes); value.setVersion(0); value.setDeleted(false); return value; } private static Candidate candidate(int boxes) { return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, - "FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5")); + "FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5")); } private static ContainerBookingEntity booking() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index f750c5f6a..5a43be896 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -5,15 +5,21 @@ import com.baiyue.firstleg.domain.TruckAppointmentStatus; import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest; import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest; import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity; import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity; import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.repository.TruckAppointmentRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +import com.baiyue.firstleg.client.TruckAppointmentFileClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.common.core.api.ApiResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -26,6 +32,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper; import java.lang.reflect.Constructor; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -36,10 +43,46 @@ class TruckAppointmentServiceTest { @Mock TruckAppointmentRepository repository; @Mock OceanBookingRepository oceanBookings; @Mock SmartLoadingRepository loadingPlans; + @Mock ContainerRepository containers; + @Mock WarehouseSupplierSnapshotService suppliers; + @Mock IamCustomerSnapshotService customers; + @Mock TruckAppointmentFileClient files; + @Mock FirstlegDocumentNumberGenerator documentNumbers; @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); @InjectMocks TruckAppointmentService service; - @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + service.internalToken = "test-internal-token"; + lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001"); + when(customers.active(anyLong(), any())).thenAnswer(invocation -> + new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7", + "Canonical Customer", "ACTIVE", null, null)); + when(suppliers.trucking(any(), any())).thenReturn( + new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE")); + lenient().when(repository.attachments(anyLong())).thenReturn(List.of()); + } + + @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() { + TruckAppointmentEntity appointment = new TruckAppointmentEntity(); + appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L); + when(repository.find(51L)).thenReturn(appointment); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61"); + booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L); + booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co"); + when(oceanBookings.find(61L)).thenReturn(booking); + ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L); + container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567"); + when(containers.find(71L)).thenReturn(container); + + TruckAppointmentEntity detail = service.detail(51L); + + assertThat(detail.getOceanBookingNo()).isEqualTo("OB61"); + assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit"); + assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line"); + assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co"); + assertThat(detail.getCabinetNo()).isEqualTo("CAB-71"); + assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567"); + } @Test void createRequiresZeroVersionAndInitialisesDraft() { SaveRequest invalid = request(1); @@ -47,47 +90,148 @@ class TruckAppointmentServiceTest { verifyNoInteractions(repository); } - @Test void createRejectsBookingThatIsNotReleased() { + @Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); - when(request.oceanBookingId()).thenReturn(99L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); - when(oceanBookings.find(99L)).thenReturn(booking); - assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱"); - verify(repository, never()).insert(any()); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(null); + when(request.containerBookingId()).thenReturn(null); + when(request.loadingPlanId()).thenReturn(null); + when(request.warehouseId()).thenReturn(null); + + TruckAppointmentEntity created = service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getOceanBookingId()).isNull(); + assertThat(entity.getValue().getContainerBookingId()).isNull(); + assertThat(entity.getValue().getLoadingPlanId()).isNull(); + assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); + assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier"); + assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001"); + assertThat(created.getAttachmentCount()).isZero(); + verifyNoInteractions(oceanBookings, containers, loadingPlans); } - @Test void createAcceptsReleasedBookingAndWritesDraft() { + @Test void createAllowsDraftWithoutBillOfLadingOrAttachment() { + SaveRequest request = validRequest(); + + service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getBillOfLadingNo()).isNull(); + verifyNoInteractions(files); + } + + @Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); + booking.setBillOfLadingNo(" BL-BOOKING-1 "); + when(oceanBookings.find(99L)).thenReturn(booking); + + service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1"); + + SaveRequest mismatch = validRequest(); + when(mismatch.oceanBookingId()).thenReturn(99L); + when(mismatch.billOfLadingNo()).thenReturn("BL-OTHER"); + assertThatThrownBy(() -> service.create(7L, "admin", mismatch)) + .isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致"); + } + + @Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); when(request.oceanBookingId()).thenReturn(99L); when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.containerBookingId()).thenReturn(null); when(request.loadingPlanId()).thenReturn(null); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); when(oceanBookings.find(99L)).thenReturn(booking); service.create(7L, "admin", request); - verify(repository).insert(any(TruckAppointmentEntity.class)); + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getCustomerId()).isEqualTo(7L); + assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); + assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L); + assertThat(entity.getValue().getContainerBookingId()).isNull(); + assertThat(entity.getValue().getLoadingPlanId()).isNull(); + assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L); verify(repository).history(any(TruckAppointmentHistoryEntity.class)); } + @Test void createRejectsBookingFromAnotherCustomer() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setCustomerId(17L); + when(oceanBookings.find(99L)).thenReturn(booking); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("customerId").hasMessageContaining("订舱"); + verify(repository, never()).insert(any()); + } + + @Test void createWithBookingAndContainerRequiresTheirAllocation() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + when(request.containerBookingId()).thenReturn(88L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L); + ContainerBookingEntity container = new ContainerBookingEntity(); + container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L); + when(oceanBookings.find(99L)).thenReturn(booking); + when(containers.find(88L)).thenReturn(container); + when(oceanBookings.allocations(99L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱"); + verify(repository, never()).insert(any()); + } + + @Test void createRejectsLoadingPlanWithoutContainerAssociation() { + SaveRequest request = validRequest(); + when(request.loadingPlanId()).thenReturn(77L); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId") + .hasMessageContaining("containerBookingId"); + verify(repository, never()).insert(any()); + } + @Test void createRejectsLoadingPlanFromAnotherContainer() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); - when(oceanBookings.containerBooking(88L)).thenReturn(container); + when(containers.find(88L)).thenReturn(container); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setContainerBookingId(88L); when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation)); - LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L); - when(loadingPlans.plan(77L)).thenReturn(plan); + LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L); + when(loadingPlans.planByBooking(88L)).thenReturn(plan); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("柜体"); + .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划"); verify(repository, never()).insert(any()); } @@ -97,17 +241,21 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); - when(oceanBookings.containerBooking(88L)).thenReturn(container); + when(containers.find(88L)).thenReturn(container); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setContainerBookingId(88L); when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation)); - when(loadingPlans.plan(77L)).thenReturn(null); + when(loadingPlans.planByBooking(88L)).thenReturn(null); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在"); + .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划"); verify(repository, never()).insert(any()); } @@ -145,6 +293,57 @@ class TruckAppointmentServiceTest { verify(repository, never()).history(any()); } + @Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() { + TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(missingBill); + + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-no-bill", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("提单号"); + + TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + missingAttachment.setBillOfLadingNo("BL-1"); + missingAttachment.setCustomerId(7L); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(missingAttachment); + when(repository.attachments(1L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-no-file", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("有效提单附件"); + verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt()); + } + + @Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + before.setCustomerId(7L); before.setOceanBookingId(99L); before.setBillOfLadingNo(null); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2); + after.setCustomerId(7L); after.setOceanBookingId(99L); after.setBillOfLadingNo("BL-BOOKING-1"); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1"); + TruckAppointmentAttachmentEntity attachment = new TruckAppointmentAttachmentEntity(); + attachment.setFileId(88L); attachment.setAttachmentType("BILL_OF_LADING"); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(before, after); + when(oceanBookings.find(99L)).thenReturn(booking); + when(repository.attachments(1L)).thenReturn(List.of(attachment)); + when(files.metadata("test-internal-token", 7L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 88L)) + .thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(88L, 7L, + "TRUCK_APPOINTMENT_ATTACHMENT", "1", "bill.pdf", "hash", "application/pdf", 10L))); + when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1); + + TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-ok", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED); + assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1"); + verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + @Test void actionRejectsMissingKeyAndMismatchedClientHash() { var request = new ActionRequest(0, "提交"); assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin")) @@ -184,6 +383,56 @@ class TruckAppointmentServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); } + @Test void pdaCompletionDoesNotRequireTruckAppointment() { + when(repository.activeForUpdate(99L)).thenReturn(List.of()); + + service.completeLoadingFromPda(99L, 20L, 7L, "admin"); + + verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5); + when(repository.activeForUpdate(99L)).thenReturn(List.of(before)); + when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1); + when(repository.find(1L)).thenReturn(after); + + service.completeLoadingFromPda(99L, 20L, 7L, "admin"); + + verify(repository).completeLoadingFromPda(1L, "LOADING", 4); + ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); + verify(repository).history(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING"); + assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED"); + assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING"); + assertThat(history.getValue().getOperatorId()).isEqualTo(7L); + assertThat(history.getValue().getReason()).contains("20"); + } + + @Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() { + when(repository.activeForUpdate(99L)).thenReturn(List.of( + entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2))); + + assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约"); + + verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4); + when(repository.activeForUpdate(99L)).thenReturn(List.of(before)); + when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0); + + assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("并发修改"); + + verify(repository, never()).history(any()); + } + private String requestHash(String action, long id, Object request) throws Exception { String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request); return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") @@ -201,4 +450,17 @@ class TruckAppointmentServiceTest { return (SaveRequest) c.newInstance(args); } catch (ReflectiveOperationException ex) { throw new AssertionError(ex); } } + + private static SaveRequest validRequest() { + SaveRequest request = mock(SaveRequest.class); + when(request.expectedVersion()).thenReturn(0); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(null); + when(request.containerBookingId()).thenReturn(null); + when(request.loadingPlanId()).thenReturn(null); + when(request.warehouseId()).thenReturn(null); + return request; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java new file mode 100644 index 000000000..f836b05dc --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java @@ -0,0 +1,72 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import com.baiyue.common.core.api.ApiResponse; + +class WarehouseSupplierSnapshotServiceTest { + @Mock WarehouseSupplierClient client; + private WarehouseSupplierSnapshotService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new WarehouseSupplierSnapshotService(); + ReflectionTestUtils.setField(service, "client", client); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void rejectsFreeTextWithoutSupplierId() { + assertThatThrownBy(() -> service.trucking(null, "任意承运商")) + .isInstanceOf(BizException.class).hasMessageContaining("自由文本"); + verifyNoInteractions(client); + } + + @Test + void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE"))); + var snapshot = service.booking(7L, "海运代理"); + assertThat(snapshot.name()).isEqualTo("海运代理"); + verify(client).get("internal-token", 7L, true, "BOOKING"); + } + + @Test + void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() { + when(client.get("internal-token", 8L, true, null)) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 8L, "任意启用供应商", "CERTIFICATE", "ACTIVE"))); + + assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商"); + verify(client).get("internal-token", 8L, true, null); + } + + @Test + void rejectsClientNameThatDiffersFromWarehouseSnapshot() { + when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE"))); + assertThatThrownBy(() -> service.customs(9L, "伪造名称")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } + + @Test + void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 7L, "错误代理", "TRUCKING", "ACTIVE"))); + + assertThatThrownBy(() -> service.booking(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java index 716e160eb..f5deb15f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.split.controller; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -13,14 +12,19 @@ class PlatformSplitPreloadControllerContractTest { @Test void shouldDeclareStableOperationsAndButtonPermissions() throws Exception { assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class); - assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); - assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); assertThat(PlatformSplitPreloadController.class .getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class).getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('firstleg:inbound:preload-view')"); } + @Test + void doesNotExposeInboundScopedPreloadConfirmation() { + assertThat(PlatformSplitPreloadController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("confirm") + || method.getName().equals("confirming")); + } + private void assertAuthority(String name, String expected, Class requestType) throws Exception { Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name, com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index b5cf588e7..267aaaf36 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -1,8 +1,5 @@ package com.baiyue.firstleg.split.service; -import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; -import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; @@ -28,12 +25,11 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class SplitPreloadServiceTest { @Mock SplitRepository repository; - @Mock LoadingModelBridgeService loadingModelBridge; - @Mock InboundStatusHistoryMapper histories; @InjectMocks SplitPreloadService service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -57,52 +53,31 @@ class SplitPreloadServiceTest { } @Test - void confirmsPlanAndMovesInboundToLoading() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4)); - PreloadPlanEntity plan = new PreloadPlanEntity(); - plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); - plan.setItemsJson("[]"); - when(repository.findPlan(10, 20)).thenReturn(plan); - org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20); - when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1); - when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1); - SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3", - new ConfirmPreloadRequest(1)); - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(loadingModelBridge).requireStarted(10, 20); - verify(repository).beginLoading(10, 20, 7, 4); + void loadingAllocationCreatesPendingPreloadLifecycle() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); + verify(repository).createPlan(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); + assertThat(captor.getValue().getItemsJson()).isEqualTo("[]"); + assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立"); } @Test - void startsPreloadConfirmationAfterLoadingBeginsWithLinkedContainerGate() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5)); + void loadingAllocationDoesNotOverwriteConfirmedPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); - plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("PENDING"); plan.setVersion(3); - plan.setItemsJson("[]"); + plan.setStatus("CONFIRMING"); when(repository.findPlan(10, 20)).thenReturn(plan); - org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20); - when(repository.changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3)).thenReturn(1); - SplitActionResponse result = service.startConfirming(10, 20, 7, "admin", "k4", - new ConfirmPreloadRequest(3)); + assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("已进入确认流程"); - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(loadingModelBridge).requireStarted(10, 20); - verify(repository).changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3); + verify(repository, never()).createPlan(any()); } - @Test - void rejectsLoadingPreloadConfirmationWhenLinkedContainersHaveNotStarted() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5)); - org.mockito.Mockito.doThrow(new IllegalStateException("not started")) - .when(loadingModelBridge).requireStarted(10, 20); - - assertThatThrownBy(() -> service.startConfirming(10, 20, 7, "admin", "k5", - new ConfirmPreloadRequest(3))) - .isInstanceOf(IllegalStateException.class) - .hasMessage("not started"); - } } diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 5dacabec2..c2778a083 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; @@ -101,8 +103,11 @@ class GatewayFileRouteConfigurationTest { assertThat(route.getUri().toString()).isEqualTo("lb://audit-service"); assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { assertThat(predicate.getName()).isEqualTo("Path"); - assertThat(String.join(",", predicate.getArgs().values())) - .contains("/api/platform/v1/audit/**"); + String pattern = String.join(",", predicate.getArgs().values()); + assertThat(pattern).contains("/api/platform/v1/audit/**"); + var matcher = PathPatternParser.defaultInstance.parse("/api/platform/v1/audit/**"); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs"))).isTrue(); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs/8"))).isTrue(); }); } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index e7def5792..c59fc9cd9 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -16,6 +16,8 @@ import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -106,6 +108,18 @@ public class InternalTenantController { return ApiResponse.success(tenantService.orderEligibility(tenantId)); } + @GetMapping("/finance-customers") + public ApiResponse> financeCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @RequestParam(required = false) Long customerId) { + verify(token); + return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId)); + } + @GetMapping("/exports/customers") public ApiResponse exportCustomers( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -113,7 +127,7 @@ public class InternalTenantController { @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) { verify(token); return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 0bf0ca592..d51fe0878 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -13,7 +13,7 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.CreationResponse; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; -import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; @@ -49,7 +49,7 @@ public class PlatformTenantController { @GetMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:list')") - @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") + @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), @@ -62,7 +62,7 @@ public class PlatformTenantController { @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); @@ -179,7 +179,7 @@ public class PlatformTenantController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域") }) - public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pendingReviews()); } diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java index b23f2d870..c17354d7a 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java @@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.RbacDtos.*; import com.baiyue.iam.entity.IamPermission; -import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.service.RbacService; import com.baiyue.iam.service.TenantGuard; import io.swagger.v3.oas.annotations.Operation; @@ -45,7 +44,7 @@ public class RbacController { @PostMapping("/roles") @Operation(summary = "创建角色") @PreAuthorize("hasAuthority(#app + ':role:create')") - public ApiResponse createRole(@PathVariable String app, + public ApiResponse createRole(@PathVariable String app, @Valid @RequestBody CreateRoleRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); diff --git a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java index b7e8936e8..d1f6f488a 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; import java.util.List; /** 企业成员接口的数据契约。 */ @@ -44,8 +45,9 @@ public final class MemberDtos { public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } - public record MemberResponse(Long membershipId, Long userId, Long authUserId, + public record MemberResponse(Long membershipId, Long userId, Long authUserId, String username, String displayName, String phone, String email, - String memberType, String memberNo, String status, List roleIds) { } + String memberType, String memberNo, String status, LocalDateTime lastLoginAt, + List roleIds) { } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java index c6719b793..716f8950a 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java @@ -25,7 +25,7 @@ public final class RbacDtos { /** 角色回显模型,permissionIds 可直接用于全量授权保存。 */ public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode, String roleName, String roleType, String status, - String description, Set permissionIds) { } + String description, long memberCount, Set permissionIds) { } public record MemberRolesResponse(Long membershipId, Set roleIds) { } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 728925b1f..40e3422e9 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -101,6 +101,21 @@ public final class TenantDtos { @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment ) { } + @Schema(name = "PendingEnterpriseReviewResponse", description = "Pending enterprise review with the customer summary required by platform reviewers / 待审核企业及客户摘要") + public record PendingReviewResponse( + @Schema(description = "Review ID / 审核单 ID", example = "190000000000000001") Long id, + @Schema(description = "Customer ID / 客户 ID", example = "190000000000000002") Long tenantId, + @Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType, + @Schema(description = "Review status / 审核状态", example = "PENDING") String status, + @Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName, + @Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode, + @Schema(description = "Primary contact / 联系人", example = "张三") String contactName, + @Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail, + @Schema(description = "Review submission time / 提交审核时间") LocalDateTime submittedAt, + @Schema(description = "Review comment / 审核意见", nullable = true) String reviewComment + ) { } + @Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据") public record ApprovalResponse( @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, @@ -124,4 +139,8 @@ public final class TenantDtos { LocalDate establishedDate, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } + + /** Full customer contact summary for trusted internal finance read models. */ + public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java index 7e33c6fba..300cb1377 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java @@ -31,6 +31,8 @@ public class PlatformNotificationController { public ApiResponse status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));} @GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')") public ApiResponse> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));} + @GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')") + public ApiResponse taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));} @PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));} @PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));} private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");} diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java index c25ad297c..5e138668e 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -28,10 +28,10 @@ public final class NotificationDtos { @Size(max = 128) String sourceEventId, @JsonIgnore @Size(max = 128) String sourceEventType) { } - public record NotificationSummary(Long id, String category, String title, String status, + public record NotificationSummary(Long id, String category, String title, String summary, String status, LocalDateTime sentAt, LocalDateTime readAt) { } - public record NotificationDetail(Long id, String category, String title, String body, + public record NotificationDetail(Long id, String category, String title, String summary, String body, String status, String sourceType, String sourceId, LocalDateTime sentAt, LocalDateTime readAt) { } @@ -69,4 +69,12 @@ public final class NotificationDtos { public record TaskListItem(Long id, String taskNo, String category, String title, String status, int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt, boolean duplicated) { } + + public record TaskRecipientItem(Long id, String displayName, String appCode, String status, + String failureReason, LocalDateTime deliveredAt) { } + + public record TaskDetail(Long id, String taskNo, String category, String title, String status, + int recipientCount, int attemptCount, LocalDateTime scheduledAt, + LocalDateTime sentAt, String lastError, + List recipients) { } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java index 0d58a2fef..427b67e18 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java @@ -6,6 +6,35 @@ import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Param; import java.time.LocalDateTime; @Mapper public interface NotificationTaskMapper extends BaseMapper { + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'SCHEDULED' + AND scheduled_at <= #{now} + AND attempt_count < #{maxAttempts} + """) + int claimScheduled(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'RETRYING' + AND updated_at <= #{dueBefore} + AND attempt_count < #{maxAttempts} + """) + int claimRetrying(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("dueBefore") LocalDateTime dueBefore, + @Param("now") LocalDateTime now); + @Update(""" UPDATE iam_notification_task task SET task.status = 'DEAD', @@ -27,15 +56,36 @@ import java.time.LocalDateTime; int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, @Param("now") LocalDateTime now); + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'DEAD', + task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND task.attempt_count >= #{maxAttempts} + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + """) + int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + @Update(""" UPDATE iam_notification_task task SET task.status = 'RETRYING', - task.attempt_count = task.attempt_count + 1, task.last_error = 'DISPATCH_TIMEOUT_RECOVERED', task.updated_at = #{now} WHERE task.status = 'DISPATCHING' AND task.updated_at <= #{staleBefore} AND task.sent_at IS NULL + AND task.attempt_count < #{maxAttempts} AND NOT EXISTS ( SELECT 1 FROM iam_notification_recipient recipient WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' @@ -45,5 +95,19 @@ import java.time.LocalDateTime; ) """) int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, - @Param("now") LocalDateTime now); + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + + @Update(""" + UPDATE iam_notification_task + SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END, + last_error = #{error}, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'DISPATCHING' + """) + int failDispatch(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("error") String error, + @Param("now") LocalDateTime now); } diff --git a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java index 5f5e76809..24431b598 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java +++ b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java @@ -49,21 +49,43 @@ public class NotificationRepository { public void insertRecipient(NotificationRecipient value) { recipients.insert(value); } public List recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper().eq(NotificationRecipient::getTaskId,taskId)); } public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); } - public List dueTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,java.time.LocalDateTime.now()).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } - public boolean claimScheduled(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"SCHEDULED") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } - public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now) { - int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); - return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now); + public List dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } + public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) { + return tasks.claimScheduled(taskId,maxAttempts,now)==1; } - public List retryingTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } - public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"RETRYING") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } + public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) { + int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); + dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts); + return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts); + } + public List retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts) + .le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } + public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) { + return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1; + } + public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING") + .set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now) + .set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; } + public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) { + return tasks.failDispatch(taskId,maxAttempts,error,now)==1; + } + public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD") + .set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0) + .set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null) + .set(NotificationTask::getUpdatedAt,now))==1; } public void insertInbox(NotificationInbox value) { inbox.insert(value); } + public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null) + .set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; } + public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error) + .set(NotificationRecipient::getUpdatedAt,now)); } public PageResult pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) { return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper() .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId) diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java new file mode 100644 index 000000000..3d8bc076d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java @@ -0,0 +1,74 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class NotificationDispatchTransactionService { + static final int MAX_DISPATCH_ATTEMPTS = 3; + static final long RETRY_DELAY_SECONDS = 30; + + @Resource private NotificationRepository repository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimScheduled(Long taskId) { + return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimRetrying(Long taskId) { + LocalDateTime now = LocalDateTime.now(); + return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS, + now.minusSeconds(RETRY_DELAY_SECONDS), now); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deliverClaimed(Long taskId) { + NotificationTask task = repository.task(taskId); + if (task == null || !"DISPATCHING".equals(task.getStatus())) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + LocalDateTime now = LocalDateTime.now(); + for (NotificationRecipient recipient : repository.recipients(taskId)) { + if ("DELIVERED".equals(recipient.getStatus())) continue; + NotificationInbox item = new NotificationInbox(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId()); + item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId()); + item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle()); + item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId()); + item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now); + repository.insertInbox(item); + if (!repository.markRecipientDelivered(recipient.getId(), now)) { + throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST"); + } + } + if (!repository.completeDispatch(taskId, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(Long taskId, Throwable failure) { + LocalDateTime now = LocalDateTime.now(); + String error = boundedError(failure); + repository.markUndeliveredRecipientsFailed(taskId, error, now); + if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + private String boundedError(Throwable failure) { + String message = failure.getMessage(); + String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + return error.length() <= 1000 ? error : error.substring(0, 1000); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java index 9b1e8bc36..4a7285aac 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java @@ -25,6 +25,7 @@ import java.util.Set; @Service public class NotificationService { private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5; + private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120; public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING } public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { } @@ -33,6 +34,7 @@ public class NotificationService { @Resource private NotificationTemplateRenderer renderer; @Resource private NotificationRecipientResolver resolver; @Resource private ObjectMapper objectMapper; + @Resource private NotificationDispatchTransactionService dispatchTransactions; @Transactional(readOnly = true) public PageResult inbox(long tenantId, long membershipId, String appCode, @@ -183,35 +185,44 @@ public class NotificationService { public int recoverTimedOutDispatching(long timeoutSeconds) { if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive"); LocalDateTime now = eventNow(); - return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now); + return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now, + NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS); } @Transactional(readOnly = true) public List dueScheduledTaskIds() { - return repository.dueTasks().stream().map(NotificationTask::getId).toList(); + return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList(); } @Transactional(readOnly = true) public List retryingTaskIds() { - return repository.retryingTasks().stream().map(NotificationTask::getId).toList(); + LocalDateTime dueBefore = LocalDateTime.now() + .minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS); + return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS) + .stream().map(NotificationTask::getId).toList(); } - @Transactional public boolean dispatchScheduled(Long taskId) { - if (!repository.claimScheduled(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimScheduled(taskId)); } - @Transactional public boolean dispatchRetrying(Long taskId) { - if (!repository.claimRetrying(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimRetrying(taskId)); + } + + private boolean dispatch(Long taskId, boolean claimed) { + if (!claimed) return false; + try { + dispatchTransactions.deliverClaimed(taskId); + return true; + } catch (RuntimeException failure) { + try { + dispatchTransactions.recordFailure(taskId, failure); + } catch (RuntimeException persistenceFailure) { + failure.addSuppressed(persistenceFailure); + } + throw failure; + } } @Transactional(readOnly = true) @@ -280,6 +291,19 @@ public class NotificationService { source.page(), source.size(), source.total(), source.pages()); } + @Transactional(readOnly = true) + public TaskDetail taskDetail(Long id) { + NotificationTask task = requiredTask(id); + List recipients = repository.recipients(id).stream() + .map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(), + value.getStatus(), value.getFailureReason(), value.getDeliveredAt())) + .toList(); + return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(), + task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(), + task.getScheduledAt(), task.getSentAt(), + task.getLastError(), recipients); + } + @Transactional @BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK", resourceId = "#id", description = "取消消息任务", captureAfter = false) @@ -298,8 +322,9 @@ public class NotificationService { NotificationTask task = requiredTask(id); if (!Set.of("RETRYING", "DEAD").contains(task.getStatus())) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); - task.setStatus("RETRYING"); task.setAttemptCount(task.getAttemptCount() + 1); repository.saveTask(task); - deliver(task, LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now); return taskItem(task, false); } @@ -319,12 +344,21 @@ public class NotificationService { return value; } private NotificationSummary summary(NotificationInbox value) { - return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(), - value.getSentAt(), value.getReadAt()); + return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt()); + } + + private String boundedSummary(String body) { + if (body == null || body.isBlank()) return ""; + String compact = body.strip().replaceAll("\\s+", " "); + int count = compact.codePointCount(0, compact.length()); + if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact; + return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS)); } private NotificationDetail detail(NotificationInbox value) { - return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), value.getBody(), - value.getStatus(), value.getSourceType(), value.getSourceId(), value.getSentAt(), value.getReadAt()); + return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(), + value.getSourceId(), value.getSentAt(), value.getReadAt()); } private TemplateResponse template(NotificationTemplate value) { return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(), diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java index 97360fc86..ae4f2d17e 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java @@ -44,14 +44,7 @@ public class CustomerListExportProviderRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status); if (StringUtils.hasText(keyword)) { - wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + wrapper.like(IamTenant::getTenantName, keyword); } return wrapper; } diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index c1507098e..629bfe02b 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; /** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */ @@ -132,6 +134,23 @@ public class RbacRepository { .eq(IamMembershipRole::getRoleId, roleId)) > 0; } + public long countRoleMembers(long tenantId, Long roleId) { + return membershipRoleMapper.selectCount(new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getRoleId, roleId)); + } + + public Map findRoleMemberCounts(long tenantId, List roleIds) { + if (roleIds.isEmpty()) return Map.of(); + List assignments = membershipRoleMapper.selectList( + new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .in(IamMembershipRole::getRoleId, roleIds)); + Map counts = new LinkedHashMap<>(); + assignments.forEach(assignment -> counts.merge(assignment.getRoleId(), 1L, Long::sum)); + return counts; + } + public List findAssignedAuthUserIds(long tenantId, Long roleId) { return membershipRoleMapper.findAssignedAuthUserIds(tenantId, roleId); } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index f023f730b..04e432e32 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Collection; /** * 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。 @@ -48,6 +49,12 @@ public class TenantRepository { } public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + public List findMerchantsByIds(Collection tenantIds) { + if (tenantIds == null || tenantIds.isEmpty()) return List.of(); + return tenantMapper.selectList(new LambdaQueryWrapper() + .in(IamTenant::getId, tenantIds) + .eq(IamTenant::getTenantType, "MERCHANT")); + } /** * Serializes the draft qualification workflow for one enterprise. The file service and the * merchant submit action run in separate requests, so qualification-row locks alone cannot @@ -91,15 +98,28 @@ public class TenantRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status) .orderByDesc(IamTenant::getCreatedAt); + if (StringUtils.hasText(keyword)) { + wrapper.like(IamTenant::getTenantName, keyword); + } + return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); + } + + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(customerId != null, IamTenant::getId, customerId) + .in(!StringUtils.hasText(status), IamTenant::getStatus, List.of("ACTIVE", "DISABLED")) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByDesc(IamTenant::getCreatedAt); if (StringUtils.hasText(keyword)) { wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); } return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); } diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index dce0133f6..403039863 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -31,7 +31,7 @@ public class CustomerListExportProviderService { private static final int MAX_BATCH_SIZE = 500; private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; private static final Set STATUSES = Set.of( - "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED"); + "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED"); private static final Set PARAMETER_KEYS = Set.of("keyword", "status"); private static final List COLUMNS = List.of( column("id", "客户ID", ExportColumnType.STRING, 20), @@ -205,6 +205,7 @@ public class CustomerListExportProviderService { case "ACTIVE" -> "启用"; case "REJECTED" -> "已驳回"; case "DISABLED" -> "已停用"; + case "ARCHIVED" -> "已归档"; default -> status; }; } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index bc1c9b896..a2a9088a2 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -192,8 +192,9 @@ public class MemberService { return response(tenantId, membership, user); } private MemberResponse response(long tenantId, IamMembership m, IamUser u) { - return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(), - u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), + AuthAccountClient.AccountSummary account = accountSummary(u.getAuthUserId()); + return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), account.username(), u.getDisplayName(), + u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), account.lastLoginAt(), repository.findRoleIds(tenantId, m.getId())); } private IamMembership required(long tenantId, Long membershipId) { diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 2645ca088..6f1669ee6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -38,19 +38,24 @@ public class RbacService { private String internalToken; public List roles(long tenantId, String appCode) { - return repository.findRoles(tenantId, appCode).stream() - .map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()))) + List roles = repository.findRoles(tenantId, appCode); + Map memberCounts = repository.findRoleMemberCounts( + tenantId, roles.stream().map(IamRole::getId).toList()); + return roles.stream() + .map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()), + memberCounts.getOrDefault(role.getId(), 0L))) .toList(); } public RoleResponse role(long tenantId, String appCode, Long roleId) { IamRole role = requireRole(tenantId, appCode, roleId); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } @BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode", description = "创建角色", captureBefore = false, captureAfter = true) - public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) { + public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) { if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) { throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); } @@ -69,7 +74,7 @@ public class RbacService { // 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。 throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); } - return role; + return response(role, Set.of(), 0L); } @BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId", @@ -79,7 +84,8 @@ public class RbacService { role.setRoleName(request.roleName()); role.setDescription(request.description()); repository.updateRole(role); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } @BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId", @@ -91,7 +97,8 @@ public class RbacService { role.setStatus(request.status()); repository.updateRole(role); revokeAssignedSessions(tenantId, roleId); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } /** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */ @@ -324,10 +331,10 @@ public class RbacService { return membership; } - private RoleResponse response(IamRole role, Set permissionIds) { + private RoleResponse response(IamRole role, Set permissionIds, long memberCount) { return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(), role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(), - Set.copyOf(permissionIds)); + memberCount, Set.copyOf(permissionIds)); } private BizException forbidden() { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 939e7a406..5d4195185 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -21,6 +21,8 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; +import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; @@ -39,7 +41,10 @@ import feign.FeignException; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。 @@ -94,7 +99,7 @@ public class TenantService { captureBefore = false, captureAfter = false) public TenantResponse archiveMerchant(long tenantId) { IamTenant tenant = requireMerchantForUpdate(tenantId); - if ("DISABLED".equals(tenant.getStatus())) return response(tenant); + if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant); if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档"); } @@ -107,7 +112,7 @@ public class TenantService { } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); } - tenant.setStatus("DISABLED"); + tenant.setStatus("ARCHIVED"); repository.saveTenant(tenant); return response(tenant); } @@ -190,8 +195,17 @@ public class TenantService { return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword()); } - public List pendingReviews() { - return repository.findPendingReviews(); + public List pendingReviews() { + List reviews = repository.findPendingReviews(); + if (reviews.isEmpty()) return List.of(); + Map tenants = repository.findMerchantsByIds(reviews.stream() + .map(IamTenantReview::getTenantId) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(IamTenant::getId, Function.identity())); + return reviews.stream() + .map(review -> pendingReviewResponse(review, tenants.get(review.getTenantId()))) + .toList(); } public PageResult pageMerchants(long page, long size, String keyword, String status) { @@ -200,6 +214,14 @@ public class TenantService { source.page(), source.size(), source.total(), source.pages()); } + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + PageResult source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); + return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getContactName(), tenant.getContactPhone())).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) { int boundedLimit = Math.min(Math.max(limit, 1), 500); List source = repository.exportMerchants(cursor, boundedLimit, keyword, status); @@ -343,6 +365,7 @@ public class TenantService { description = "平台编辑企业客户档案") public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); String contactPhone = normalizeContactPhone(request.contactPhone()); String existingContactPhone = normalizeContactPhone(tenant.getContactPhone()); if (!contactPhone.equals(existingContactPhone)) { @@ -423,6 +446,7 @@ public class TenantService { resourceId = "#tenantId", description = "编辑企业资料") public TenantResponse update(long tenantId, UpdateProfileRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); @@ -437,6 +461,12 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private void ensureNotArchived(IamTenant tenant) { + if ("ARCHIVED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看"); + } + } + private String normalizeContactPhone(String contactPhone) { return contactPhone == null ? "" : contactPhone.trim(); } @@ -483,6 +513,17 @@ public class TenantService { ); } + private PendingReviewResponse pendingReviewResponse(IamTenantReview review, IamTenant tenant) { + return new PendingReviewResponse( + review.getId(), review.getTenantId(), review.getReviewType(), review.getStatus(), + tenant == null ? null : tenant.getTenantName(), + tenant == null ? null : tenant.getTenantCode(), + tenant == null ? null : tenant.getContactName(), + tenant == null ? null : tenant.getContactPhone(), + tenant == null ? null : tenant.getContactEmail(), + review.getCreatedAt(), review.getReviewComment()); + } + private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index d3b3c5038..df24054b9 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -21,7 +21,15 @@ VALUES (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), - (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0) + (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), + (5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0), + (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), + (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), + (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), + (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), + (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), @@ -54,7 +62,12 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) AND p.permission_code IN ( 'platform:finance:customer-account:list','platform:finance:customer-arap:list', 'platform:finance:customer-statement:list','platform:finance:settlement-ar:list', - 'platform:finance:settlement-ap:list','platform:firstleg:inbound:measure:view', + 'platform:finance:settlement-ap:list','platform:finance:customer-account:export', + 'platform:finance:customer-arap:export','platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export', + 'platform:finance:receivable-statistics:list','platform:finance:receivable-statistics:export', + 'platform:download:list', + 'platform:firstleg:inbound:measure:view', 'firstleg:inbound:measurement-review','platform:supplier:list', 'platform:supplier:create','platform:supplier:edit','platform:supplier:status', 'platform:domestic-container:list','platform:domestic-container:inbound:create', @@ -74,7 +87,7 @@ UPDATE iam_permission p UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code, 5105041 parent_id UNION ALL - SELECT 'platform:finance:fee-source:settlement', 5105041 UNION ALL + SELECT 'platform:finance:fee-source:edit', 5105041 UNION ALL SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue', 5105042 UNION ALL @@ -86,3 +99,10 @@ UPDATE iam_permission p ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) + FROM iam_role r + JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' + WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') + AND p.permission_code='merchant:finance:statement:confirm'; diff --git a/iam/src/main/resources/db/finance-g1-g2-permissions.sql b/iam/src/main/resources/db/finance-g1-g2-permissions.sql index 2f9c91515..5d979ecb0 100644 --- a/iam/src/main/resources/db/finance-g1-g2-permissions.sql +++ b/iam/src/main/resources/db/finance-g1-g2-permissions.sql @@ -3,6 +3,14 @@ USE baiyue_iam; SET NAMES utf8mb4; +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id +WHERE p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); +DELETE FROM iam_permission +WHERE tenant_id=0 AND app_code='PLATFORM' + AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); + DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; DELIMITER $$ CREATE PROCEDURE migrate_finance_g1_g2_permissions() @@ -12,10 +20,10 @@ BEGIN FROM iam_permission WHERE (id = 5105000 AND permission_code <> 'platform:finance') OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') - OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') + OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit') OR (permission_code = 'platform:finance' AND id <> 5105000) OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) - OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) + OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025) OR (id IN (5105000, 5105024, 5105025) AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) ) THEN @@ -31,8 +39,8 @@ BEGIN '财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0), (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), - (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', - '设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0) + (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit', + '编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -55,7 +63,7 @@ BEGIN AND deleted = 0 AND permission_code IN ( 'platform:finance:fee-source:review', - 'platform:finance:fee-source:settlement')) <> 2 THEN + 'platform:finance:fee-source:edit')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete'; END IF; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 2f0d3b823..65b0d2654 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', - status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', + status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index ad2f958a5..fcc5d4bde 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -38,6 +38,22 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; +-- The built-in IT customer must be immediately usable for order acceptance tests. +-- These approved metadata rows intentionally have no document URL: initialization must not +-- point at qualification files that do not exist in File service storage. +INSERT INTO iam_tenant_qualification ( + id, tenant_id, qualification_type, certificate_no, document_url, + valid_from, valid_to, status, review_comment, deleted +) VALUES + (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), + (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), + certificate_no = VALUES(certificate_no), status = 'APPROVED', + review_comment = VALUES(review_comment), deleted = 0; + INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 10709a991..5f5b22b77 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -44,8 +44,8 @@ paths: parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - - {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字} - - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} + - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}} responses: '200': description: 企业客户分页查询成功 @@ -259,7 +259,11 @@ paths: summary: List pending tenant reviews / 查询待审核企业 description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 待审核企业及客户摘要查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PendingEnterpriseReviewListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/reviews/{id}/approve: post: @@ -498,7 +502,11 @@ paths: tags: [Merchant Tenant / 商户企业] summary: List enterprise members / 查询企业成员 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 企业成员列表,包含真实登录账号与最后登录时间 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [Merchant Tenant / 商户企业] @@ -510,7 +518,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/CreateMemberRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 新增后的企业成员 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} @@ -526,7 +538,11 @@ paths: summary: 查询成员详情及当前角色 description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 企业成员详情 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} put: @@ -539,7 +555,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/UpdateMemberRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 更新后的企业成员 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} @@ -548,7 +568,11 @@ paths: tags: [IAM Resources / IAM 资源] summary: List tenant addresses / 查询企业地址 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 角色列表及各角色实时成员数 + content: + application/json: + schema: {$ref: '#/components/schemas/RoleListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [IAM Resources / IAM 资源] @@ -559,7 +583,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/AddressRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 新增后的角色及实时成员数 + content: + application/json: + schema: {$ref: '#/components/schemas/RoleApiResponse'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} /api/merchant/v1/iam/tenant/qualifications: @@ -740,6 +768,39 @@ paths: application/json: schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/finance-customers: + get: + tags: [IAM Resources / IAM 资源] + summary: 提供财务客户主数据摘要 + description: 仅供 Finance Service 调用;只返回正常/停用客户及财务账户所需的客户编号、名称和联系方式,不依赖财务表是否已有记录。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}} + - {name: status, in: query, required: false, schema: {type: string, enum: [ACTIVE, DISABLED]}} + - {name: customerId, in: query, required: false, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 财务客户主数据分页摘要 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/FinanceCustomerSummary'}} + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer} + '403': {$ref: '#/components/responses/Forbidden'} /internal/exports/v1/CUSTOMER_LIST/batches: post: tags: [Internal Export Provider] @@ -850,6 +911,60 @@ components: type: array uniqueItems: true items: {type: string} + MerchantMemberResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, memberType, status, lastLoginAt, roleIds] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string, description: 统一认证登录账号} + displayName: {type: string} + phone: {type: string, nullable: true} + email: {type: string, format: email, nullable: true} + memberType: {type: string, enum: [OWNER, EMPLOYEE]} + memberNo: {type: string, nullable: true, description: 企业内部成员编号,不作为登录账号} + status: {type: string, enum: [ACTIVE, DISABLED]} + lastLoginAt: {type: string, format: date-time, nullable: true, description: 从 Auth 账号事实返回;null 表示从未登录} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + MerchantMemberListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/MerchantMemberResponse'}} + MerchantMemberApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/MerchantMemberResponse'} + RoleResponse: + type: object + required: [id, tenantId, appCode, roleCode, roleName, roleType, status, memberCount, permissionIds] + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + roleCode: {type: string} + roleName: {type: string} + roleType: {type: string, enum: [SYSTEM, CUSTOM]} + status: {type: string, enum: [ACTIVE, DISABLED]} + description: {type: string, nullable: true} + memberCount: {type: integer, format: int64, minimum: 0, description: 当前客户及应用域内分配该角色的成员数} + permissionIds: {type: array, uniqueItems: true, items: {type: string, pattern: '^\d+$'}} + RoleListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/RoleResponse'}} + RoleApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/RoleResponse'} TenantPageApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} @@ -870,6 +985,29 @@ components: - type: object properties: data: {$ref: '#/components/schemas/TenantApprovalResponse'} + PendingEnterpriseReviewListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/PendingEnterpriseReviewResponse'} + PendingEnterpriseReviewResponse: + type: object + required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt] + properties: + id: {type: string, pattern: '^\d+$', description: 审核单 ID} + tenantId: {type: string, pattern: '^\d+$', description: 客户 ID} + reviewType: {type: string, example: REGISTER} + status: {type: string, enum: [PENDING]} + tenantName: {type: string, description: 客户名称} + tenantCode: {type: string, description: 客户编号} + contactName: {type: string, description: 联系人} + contactPhone: {type: string, nullable: true, description: 联系电话} + contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱} + submittedAt: {type: string, format: date-time, description: 提交审核时间} + reviewComment: {type: string, nullable: true, description: 审核意见} TenantCreationApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} @@ -952,7 +1090,7 @@ components: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} - status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]} + status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} @@ -971,6 +1109,16 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + FinanceCustomerSummary: + type: object + required: [id, tenantCode, tenantName, status] + properties: + id: {type: string, pattern: '^\d+$'} + tenantCode: {type: string} + tenantName: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + contactName: {type: string, nullable: true} + contactPhone: {type: string, nullable: true} TenantAddressListApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index ebb2711c3..99f5f992f 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -50,7 +50,7 @@ paths: tags: [Platform notification administration] summary: Create an IN_APP notification task requestBody: {$ref: '#/components/requestBodies/CreateNotification'} - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-templates: get: tags: [Platform notification administration] @@ -95,18 +95,26 @@ paths: - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} + /platform/v1/notification-tasks/{id}: + get: + tags: [Platform notification administration] + summary: Get notification task delivery details + description: Uses the task list permission and exposes bounded operational evidence without message body content. + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskDetail'}} /platform/v1/notification-tasks/{id}/cancel: put: tags: [Platform notification administration] summary: Cancel a CREATED or SCHEDULED task parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-tasks/{id}/retry: put: tags: [Platform notification administration] - summary: Retry a RETRYING or DEAD task + summary: Requeue a RETRYING or DEAD task + description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay. parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} components: parameters: AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} @@ -126,6 +134,7 @@ components: TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}} TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}} TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}} + TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}} TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}} schemas: CreateNotificationRequest: @@ -159,10 +168,12 @@ components: type: object required: [targetStatus, version] properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}} - InboxItem: {type: object, required: [id, category, title, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} + InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]} TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}} TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}} + TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}} + TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}} InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} @@ -173,4 +184,5 @@ components: TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}} TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}} TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}} - TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 921252509..c8d8a0959 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -23,6 +23,10 @@ class IamOpenApiContractTest { .contains("PermissionCodesApiResponse:") .contains("TenantPageApiResponse:") .contains("TenantApiResponse:") + .contains("PendingEnterpriseReviewListApiResponse:") + .contains("PendingEnterpriseReviewResponse:") + .contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]") + .contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}") .contains("TenantSuccess:") .contains("salesOwnerName:") .contains("paymentTermDays:") @@ -36,6 +40,11 @@ class IamOpenApiContractTest { .contains("TenantAddressListApiResponse:") .contains("TenantQualificationListApiResponse:") .contains("InternalUserListApiResponse:") + .contains("MerchantMemberResponse:") + .contains("username: {type: string, description: 统一认证登录账号}") + .contains("lastLoginAt: {type: string, format: date-time, nullable: true") + .contains("RoleResponse:") + .contains("memberCount: {type: integer, format: int64, minimum: 0") .contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:") .contains("AssignInternalUserRolesRequest:") .contains("/api/merchant/v1/iam/members/{membershipId}:") diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index 97345fce8..ca4efc0c5 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -40,4 +40,18 @@ class InternalCustomerExportContractTest { assertEquals(ResultCode.SUCCESS.code(), response.code()); verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); } + + @Test + void exposesFinanceCustomerMasterDataOnlyWithInternalToken() { + BizException denied = assertThrows(BizException.class, + () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE", null)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); + var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, + "Alice", "ACTIVE", null); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null); + } } diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 461ddbbc1..a6fc8ce28 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest { assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); assertEquals(200, annotation(method, 1, Max.class).value()); - assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED", annotation(method, 3, Pattern.class).regexp()); Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) @@ -46,6 +46,11 @@ class PlatformTenantControllerContractTest { assertEquals("hasAuthority('platform:customer:review')", method.getAnnotation(PreAuthorize.class).value()); + ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType(); + ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0]; + assertEquals(java.util.List.class, list.getRawType()); + assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class, + list.getActualTypeArguments()[0]); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java new file mode 100644 index 000000000..788fa540b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +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.*; +import static org.mockito.Mockito.*; + +class NotificationDispatchTransactionServiceTest { + private final NotificationRepository repository = mock(NotificationRepository.class); + private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception { + for (var method : List.of( + NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class), + NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class), + NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class), + NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) { + assertThat(method.getAnnotation(Transactional.class)).isNotNull() + .extracting(Transactional::propagation) + .isEqualTo(Propagation.REQUIRES_NEW); + } + } + + @Test void claimStartsOneOfThreeAllowedAttempts() { + when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true); + + assertThat(service.claimScheduled(41L)).isTrue(); + + verify(repository).claimScheduled(eq(41L), eq(3), any()); + } + + @Test void retryClaimEnforcesDelayAndSameAttemptLimit() { + when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true); + + assertThat(service.claimRetrying(41L)).isTrue(); + + ArgumentCaptor dueBefore = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture()); + assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30)); + } + + @Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("FAILED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L) + && item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus()))); + var ordered = inOrder(repository); + ordered.verify(repository).markRecipientDelivered(eq(51L), any()); + ordered.verify(repository).completeDispatch(eq(41L), any()); + } + + @Test void alreadyDeliveredRecipientIsNotInsertedAgain() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("DELIVERED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository, never()).insertInbox(any()); + verify(repository, never()).markRecipientDelivered(anyLong(), any()); + } + + @Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() { + when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true); + + service.recordFailure(41L, new IllegalStateException("x".repeat(1200))); + + ArgumentCaptor error = ArgumentCaptor.forClass(String.class); + verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any()); + assertThat(error.getValue()).hasSize(1000); + verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any()); + } + + @Test void rejectsDeliveryWithoutCurrentClaim() { + when(repository.task(41L)).thenReturn(task("RETRYING")); + + assertThatThrownBy(() -> service.deliverClaimed(41L)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST"); + + verify(repository, never()).insertInbox(any()); + } + + private NotificationTask task(String status) { + NotificationTask task = new NotificationTask(); + task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化"); + task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed"); + return task; + } + + private NotificationRecipient recipient(String status) { + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L); + recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status); + recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now()); + return recipient; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java index 776d2bdcc..ea97693f2 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.notification; import com.baiyue.iam.notification.controller.NotificationInboxController; +import com.baiyue.iam.notification.controller.PlatformNotificationController; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest { assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')"); } + @Test + void taskDetailUsesTheTaskListPermission() { + var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("taskDetail")) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:notification:task:list')"); + } + private static void assertPermission(String methodName, String expected) { var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index afab24b08..af6a9a172 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -28,6 +28,7 @@ class NotificationOpenApiContractTest { "/{app}/v1/notifications/read-all", "/platform/v1/notifications", "/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry"); assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create"); @@ -37,6 +38,7 @@ class NotificationOpenApiContractTest { assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put"); for (String path : List.of("/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry")) { Map operation = map(map(paths.get(path)).values().iterator().next()); @@ -50,6 +52,7 @@ class NotificationOpenApiContractTest { "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse", "InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse", "TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse"); + assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail"); for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest", "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem", "TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) { @@ -59,6 +62,13 @@ class NotificationOpenApiContractTest { .contains("DISABLED cannot return to ACTIVE") .contains("does not support deleting or clearing read messages") .contains("name: taskNo").contains("name: templateCode").contains("name: title"); + Map inboxItem = map(schemas.get("InboxItem")); + assertThat(list(inboxItem.get("required"))).contains("summary"); + assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120); + Map taskDetail = map(schemas.get("TaskDetail")); + assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) + .containsEntry("minimum", 0).containsEntry("maximum", 3); + assertThat(document).contains("scheduler delivery starts after the retry delay"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java index d199ba723..c629d44be 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java @@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class NotificationSchedulerRecoveryTest { + @Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception { + Update scheduled = NotificationTaskMapper.class + .getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + + Update retrying = NotificationTaskMapper.class + .getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + } + @Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception { Update retry = NotificationTaskMapper.class - .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) + .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) .getAnnotation(Update.class); String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase(); assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX") - .contains("SET TASK.STATUS = 'RETRYING'"); + .contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}") + .doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1"); Update dead = NotificationTaskMapper.class .getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) @@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest { assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX", "DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE"); + + Update attemptLimit = NotificationTaskMapper.class + .getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) + .getAnnotation(Update.class); + String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'", + "TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT"); + + Update failed = NotificationTaskMapper.class + .getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END", + "LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'"); } @Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java index ac6b51b98..b000b0ae0 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java @@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.notification.dto.NotificationDtos.*; import com.baiyue.iam.notification.entity.NotificationRecipient; import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baiyue.iam.notification.entity.NotificationInbox; import com.baiyue.iam.notification.entity.NotificationTask; import com.baiyue.iam.notification.entity.NotificationTemplate; import com.baiyue.iam.notification.repository.NotificationRepository; import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; import com.baiyue.iam.notification.service.NotificationService; import com.baiyue.iam.notification.service.NotificationTemplateRenderer; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,6 +29,8 @@ import static org.mockito.Mockito.*; class NotificationServiceTest { private final NotificationRepository repository = mock(NotificationRepository.class); private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class); + private final NotificationDispatchTransactionService dispatchTransactions = + mock(NotificationDispatchTransactionService.class); private final NotificationService service = new NotificationService(); @BeforeEach void setup() { @@ -34,6 +38,7 @@ class NotificationServiceTest { ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer()); ReflectionTestUtils.setField(service, "resolver", resolver); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions); } @Test void createsTaskAndImmutableRecipientSnapshot() { @@ -63,6 +68,39 @@ class NotificationServiceTest { assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED"); } + @Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() { + NotificationInbox item = new NotificationInbox(); + item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD"); + item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " "); + when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1)); + + NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0); + + assertThat(summary.summary()).startsWith("第一行 "); + assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120); + assertThat(summary.summary()).isNotEqualTo(item.getBody()); + } + + @Test void returnsTaskAndRecipientFailureEvidence() { + NotificationTask task = task(41L, "DEAD"); + task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3); + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT"); + recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + + TaskDetail detail = service.taskDetail(41L); + + assertThat(detail.attemptCount()).isEqualTo(3); + assertThat(detail.lastError()).isEqualTo("gateway timeout"); + assertThat(detail.recipients()).singleElement().satisfies(value -> { + assertThat(value.status()).isEqualTo("FAILED"); + assertThat(value.failureReason()).isEqualTo("gateway timeout"); + }); + } + @Test void returnsOriginalTaskForRepeatedIdempotencyKey() { NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5"); existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1); @@ -151,31 +189,28 @@ class NotificationServiceTest { } @Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() { - when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2); + when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2); assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2); ArgumentCaptor staleBefore = ArgumentCaptor.forClass(LocalDateTime.class); ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); - verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture()); + verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3)); assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300)); } @Test void onlyOneInstanceCanClaimRecoveredTask() { - when(repository.claimRetrying(41L)).thenReturn(true, false); - NotificationTask task = task(41L, "RETRYING"); - when(repository.task(41L)).thenReturn(task); - when(repository.recipients(41L)).thenReturn(List.of()); + when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false); assertThat(service.dispatchRetrying(41L)).isTrue(); assertThat(service.dispatchRetrying(41L)).isFalse(); - verify(repository, times(2)).claimRetrying(41L); - verify(repository, times(1)).saveTask(task); + verify(dispatchTransactions, times(2)).claimRetrying(41L); + verify(dispatchTransactions, times(1)).deliverClaimed(41L); } @Test void sentTaskCannotBeClaimedForRecoveryDelivery() { - when(repository.claimRetrying(42L)).thenReturn(false); + when(dispatchTransactions.claimRetrying(42L)).thenReturn(false); assertThat(service.dispatchRetrying(42L)).isFalse(); @@ -183,6 +218,30 @@ class NotificationServiceTest { verify(repository, never()).recipients(42L); } + @Test void persistsScheduledDeliveryFailureAfterCommittedClaim() { + IllegalStateException failure = new IllegalStateException("inbox unavailable"); + when(dispatchTransactions.claimScheduled(43L)).thenReturn(true); + doThrow(failure).when(dispatchTransactions).deliverClaimed(43L); + + assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure); + + verify(dispatchTransactions).recordFailure(43L, failure); + } + + @Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() { + NotificationTask task = task(44L, "DEAD"); + task.setAttemptCount(3); task.setLastError("timeout"); + when(repository.task(44L)).thenReturn(task); + when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true); + + TaskListItem result = service.retry(44L); + + assertThat(result.status()).isEqualTo("RETRYING"); + assertThat(task.getAttemptCount()).isZero(); + assertThat(task.getLastError()).isNull(); + verify(dispatchTransactions, never()).deliverClaimed(anyLong()); + } + private NotificationTask task(long id, String status) { NotificationTask value = new NotificationTask(); value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index fe3314af1..231d77d05 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -13,7 +13,12 @@ class AdminPrototypeMenuPermissionSqlContractTest { private static final String[] FINANCE_MENUS = { "platform:finance:customer-account:list", "platform:finance:customer-arap:list", "platform:finance:customer-statement:list", "platform:finance:settlement-ar:list", - "platform:finance:settlement-ap:list" + "platform:finance:settlement-ap:list", "platform:finance:receivable-statistics:list" + }; + private static final String[] FINANCE_EXPORTS = { + "platform:finance:customer-account:export", "platform:finance:customer-arap:export", + "platform:finance:customer-statement:export", "platform:finance:settlement-ar:export", + "platform:finance:settlement-ap:export", "platform:finance:receivable-statistics:export" }; @Test @@ -23,9 +28,11 @@ class AdminPrototypeMenuPermissionSqlContractTest { for (int index = 0; index < FINANCE_MENUS.length; index++) { assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'"); + assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") + .contains("'platform:finance:fee-source:edit', 5105041") .contains("'platform:finance:reconciliation:create', 5105042") .contains("'platform:finance:settlement-ar:create', 5105043") .contains("'platform:finance:settlement-ap:create', 5105044") @@ -36,6 +43,24 @@ class AdminPrototypeMenuPermissionSqlContractTest { .doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043") .doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044") .contains("role_code='PLATFORM_ADMIN'"); + + int grantStart = sql.indexOf("role_code='PLATFORM_ADMIN'"); + String platformAdminGrant = sql.substring(grantStart, sql.indexOf(");", grantStart)); + for (String permission : FINANCE_EXPORTS) assertThat(platformAdminGrant).contains(permission); + assertThat(platformAdminGrant).contains("platform:download:list"); + + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + for (int index = 0; index < FINANCE_EXPORTS.length; index++) { + assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); + } + assertThat(unified) + .contains("platform:finance:receivable-statistics:export") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") + .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") + .contains("Finance 导出和下载中心授权不完整") + .contains("merchant:finance:statement:confirm") + .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) + .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java index e97c8f356..0205e4283 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java @@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest { assertThat(query.getSqlSegment()) .contains("tenant_type") .contains("status") - .contains("tenant_code") .contains("tenant_name") + .doesNotContain("tenant_code") .contains("id >") .contains("id <=") .contains("ORDER BY id ASC") diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java index 243e20aff..ccf424a03 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java @@ -17,7 +17,7 @@ class FinanceG1G2PermissionSqlContractTest { assertThat(sql) .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") - .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'") + .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("ON DUPLICATE KEY UPDATE") @@ -35,7 +35,7 @@ class FinanceG1G2PermissionSqlContractTest { assertThat(sql) .contains("SIGNAL SQLSTATE '45000'") - .contains("permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025") + .contains("permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025") .contains("tenant_id <> 0 OR app_code <> 'PLATFORM'") .contains("parent_id = 5105000") .contains("permission_type = 'MENU'") @@ -52,7 +52,7 @@ class FinanceG1G2PermissionSqlContractTest { assertThat(financeSection) .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") - .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'") + .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("permission_code IN (\n 'platform:finance',") @@ -63,9 +63,9 @@ class FinanceG1G2PermissionSqlContractTest { assertThat(sql) .contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ap:list") - .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("rp.role_id=4000001") - .contains("LIKE 'platform:finance:%')) <> 28") + .contains("LIKE 'platform:finance:%')) <> 33") .contains("平台管理员 Finance 授权不完整"); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java index 8463aa869..852ad0b3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java @@ -51,9 +51,9 @@ class FinanceG3G4PermissionSqlContractTest { assertThat(sql) .contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ap:list") - .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("rp.role_id=4000001") - .contains("LIKE 'platform:finance:%')) <> 28"); + .contains("LIKE 'platform:finance:%')) <> 33"); } private static Path resolveUnifiedSql() { diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 265237dc1..e653c4c01 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -19,6 +19,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.time.LocalDateTime; import java.util.List; import java.util.Set; @@ -45,15 +46,20 @@ class MemberServiceTest { when(repository.findByTenantAndId(100L, 20L)).thenReturn(member); when(repository.findUser(30L)).thenReturn(user); when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); + when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE", + LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT"))); } @Test void shouldHandleCase01() { user.setPhone("13800138000"); user.setEmail("member@example.com"); MemberResponse result = service.detail(100L, 20L); assertEquals("测试成员", result.displayName()); + assertEquals("member01", result.username()); assertEquals("13800138000", result.phone()); + assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt()); assertEquals(List.of(50L), result.roleIds()); - verifyNoInteractions(auth); + verify(auth).summary("internal-token", 40L); } @Test void shouldReturnOwnerAccountForPlatformCustomerDetail() { diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 50e3083d5..9854bc23e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.test.context.TestPropertySource; import jakarta.annotation.Resource; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -47,11 +48,14 @@ class RbacServiceTest { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); + when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L)); var result = service.roles(100L, "MERCHANT"); assertEquals(Set.of(21L, 22L), result.get(0).permissionIds()); + assertEquals(3L, result.get(0).memberCount()); assertEquals(100L, result.get(0).tenantId()); + verify(repository, never()).countRoleMembers(anyLong(), anyLong()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index fd402ab75..ca4291de3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -30,6 +30,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; @@ -94,6 +95,55 @@ class TenantServiceTest { verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } + @Test + void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() { + IamTenantReview review = review(71L, 10001L); + review.setReviewType("REGISTER"); + review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27)); + IamTenant tenant = merchant(10001L, "PENDING"); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("alice@example.com"); + when(repository.findPendingReviews()).thenReturn(List.of(review)); + when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant)); + + var result = service.pendingReviews(); + + assertEquals(1, result.size()); + assertEquals(71L, result.get(0).id()); + assertEquals(10001L, result.get(0).tenantId()); + assertEquals("REGISTER", result.get(0).reviewType()); + assertEquals("PENDING", result.get(0).status()); + assertEquals("Example Merchant", result.get(0).tenantName()); + assertEquals("TEN10001", result.get(0).tenantCode()); + assertEquals("Alice", result.get(0).contactName()); + assertEquals("13800138000", result.get(0).contactPhone()); + assertEquals("alice@example.com", result.get(0).contactEmail()); + assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt()); + verify(repository).findMerchantsByIds(Set.of(10001L)); + verify(repository, never()).findTenant(10001L); + } + + @Test + void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null)) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + + assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + } + @Test void platformCanMaintainAndDisableOnlyMerchantTenant() { IamTenant tenant = merchant(10001L, "ACTIVE"); @@ -162,11 +212,11 @@ class TenantServiceTest { } @Test - void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() { - IamTenant tenant = merchant(10001L, "DISABLED"); + void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() { + IamTenant tenant = merchant(10001L, "ARCHIVED"); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); - assertEquals("DISABLED", service.archiveMerchant(10001L).status()); + assertEquals("ARCHIVED", service.archiveMerchant(10001L).status()); verify(authClient, never()).archiveTenant(any(), any()); verify(repository, never()).saveTenant(any()); } @@ -205,6 +255,34 @@ class TenantServiceTest { assertEquals(ResultCode.CONFLICT, exception.getResultCode()); } + @Test + void archivedCustomerCannotBeReactivated() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() { + IamTenant archived = merchant(10001L, "ARCHIVED"); + when(repository.findTenant(10001L)).thenReturn(archived); + + BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null))); + BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, + new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", + "13800138000", "alice@example.com"))); + + assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode()); + assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + @Test void merchantProfileCannotReadOrUpdatePlatformTenant() { IamTenant platform = new IamTenant(); @@ -283,7 +361,7 @@ class TenantServiceTest { var archived = service.archiveMerchant(10001L); - assertEquals("DISABLED", archived.status()); + assertEquals("ARCHIVED", archived.status()); verify(authClient).archiveTenant("internal-token", 10001L); verify(repository).saveTenant(tenant); } diff --git a/order/AGENTS.md b/order/AGENTS.md index 0e414ffb1..d37fcd787 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -4,7 +4,7 @@ - 负责头程订单主数据、货物明细、快照、状态编排、幂等、Outbox 和内部货物查询。 - 订单交易状态归本模块;履约/入库状态归 `firstleg/`,仓储资源归 `warehouse/`,不得跨库读取。 -- `tenant_id` 是客户隔离键;`customer_id` 是业务客户/往来单位快照,不能替代前者。 +- `tenant_id` 是客户隔离键;`customer_id` 是客户/企业账号 ID,商户下单时固定来自认证上下文,不得指向 Warehouse 往来单位。发货单位、收货单位只保存为订单 Party 引用与业务快照。 ## 模块入口 @@ -12,8 +12,12 @@ |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | +| 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` | +| 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | +| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | +| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 | | 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 | -| 货物内部查询 | `controller/InternalOrderCargoController.java` | +| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java` | | 快照/Outbox | `service/`、`repository/`、`event/` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | | SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` | @@ -22,6 +26,8 @@ ## 修改约束 - 所有客户查询在 Repository/Mapper 显式带 `tenant_id`;状态变更保留幂等、历史、审计和并发控制。 +- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。 +- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。 - Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。 ## 全量验收导航 diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 4b5cd813f..de4b011fb 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -44,7 +44,8 @@ public interface FirstlegInboundClient { Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, String publicRemark, - String expressCompanyName, String expressTrackingNo, Integer totalPackageCount + String expressCompanyName, String expressTrackingNo, Integer totalPackageCount, + String pickupMethod, LocalDateTime requestedPickupAt ) { } } diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java index 3953bbc3d..3a14fbe13 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -10,8 +10,10 @@ import org.springframework.web.bind.annotation.RequestHeader; url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing") public interface FirstlegPricingClient { @PostMapping("/channels/validate") - ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody ChannelValidationRequest request); + ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ChannelValidationRequest request); - record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {} + record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, + String destinationType, boolean requireInboundCodePrefix) {} + record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} } diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index cabdcfbab..c8bb829bd 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -47,10 +47,16 @@ public interface FirstlegUnifiedOrderClient { Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, String channelName, Integer totalPackageCount, String locationCodes, - BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus, + String companyName, String businessType, String taxMode, String containerMode, + Boolean customsInspection, String destinationType, String destinationCode, + BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, + BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount, BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, - String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo, - LocalDateTime sailingAt, LocalDateTime portArrivalAt, + LocalDateTime inboundedAt, String expressCompanyName, String createdByName, + String containerNo, String cabinetNo, String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {} record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} record Loading(String status, Integer loadingVersion, Integer pickingVersion, diff --git a/order/src/main/java/com/baiyue/order/client/PartnerClient.java b/order/src/main/java/com/baiyue/order/client/PartnerClient.java deleted file mode 100644 index 85fbae56e..000000000 --- a/order/src/main/java/com/baiyue/order/client/PartnerClient.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.order.client; - -import com.baiyue.common.core.api.ApiResponse; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.*; -import java.util.List; - -@FeignClient(name="warehouse-service", contextId="warehousePartnerClient", - url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners") -public interface PartnerClient { - @GetMapping("/{tenantId}/{id}/active") - ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, - @PathVariable("tenantId") long tenantId, @PathVariable("id") long id); - record Partner(Long id, Long tenantId, String partnerName, String status, - String partnerType, List types) { } -} diff --git a/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java b/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java new file mode 100644 index 000000000..93386bb8b --- /dev/null +++ b/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java @@ -0,0 +1,38 @@ +package com.baiyue.order.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.Locale; + +/** 从共享配置读取全系统唯一的入仓码运行模式,并拒绝任何未知配置。 */ +@Component +@RefreshScope +public class InboundCodeModeProvider { + public enum Mode { AUTO, LEGACY_MANUAL } + + private final Mode mode; + + public InboundCodeModeProvider( + @Value("${baiyue.order.inbound-code-mode:AUTO}") String configuredMode) { + if (!StringUtils.hasText(configuredMode)) { + throw new IllegalStateException("baiyue.order.inbound-code-mode 必须显式配置"); + } + try { + mode = Mode.valueOf(configuredMode.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException( + "baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL", exception); + } + } + + public Mode mode() { + return mode; + } + + public boolean legacyManual() { + return mode == Mode.LEGACY_MANUAL; + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 6fc2e3701..bf34d41c5 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.List; +import java.math.BigDecimal; +import java.util.Objects; +import java.util.stream.Collectors; /** 供头程履约服务校验入仓单所关联订单货物明细。 */ @RestController @@ -48,10 +51,16 @@ public class InternalOrderCargoController { if (cargo == null) { throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单"); } + var order = orders.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + String latestInternalNote = orders.findNotes(tenantId, orderId).stream() + .map(com.baiyue.order.entity.OrderInternalNoteEntity::getContent) + .filter(java.util.Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()) + .findFirst().orElse(null); return ApiResponse.success(new CargoBinding(cargo.getId(), orderId, cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(), cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(), - cargo.getNetWeightKg(), cargo.getCargoRemark())); + cargo.getNetWeightKg(), cargo.getCargoRemark(), order.getCustomerRemark(), latestInternalNote)); } @GetMapping("/{tenantId}/{orderId}") @@ -89,6 +98,32 @@ public class InternalOrderCargoController { return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount)); } + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") + public ApiResponse measurementDeclaration( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long orderId) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); + } + var order = orders.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + // 申报重量/体积必须属于当前订单。拆分子单未物化独立货物事实时不得复制根单整单总量。 + List cargoes = orders.findCargo(tenantId, orderId); + return ApiResponse.success(new MeasurementDeclarationSnapshot( + order.getReferenceId(), sum(cargoes, OrderCargoEntity::getGrossWeightKg), + sum(cargoes, OrderCargoEntity::getVolumeCbm), + cargoes.stream().map(OrderCargoEntity::getCargoName).filter(Objects::nonNull) + .map(String::trim).filter(value -> !value.isEmpty()).distinct() + .collect(Collectors.joining("、")))); + } + + private BigDecimal sum(List cargoes, + java.util.function.Function getter) { + List values = cargoes.stream().map(getter).filter(Objects::nonNull).toList(); + return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); @@ -100,12 +135,22 @@ public class InternalOrderCargoController { java.math.BigDecimal originalGrossWeightKg, java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, Integer unitsPerPackage, Integer totalQuantity, - java.math.BigDecimal netWeightKg, String cargoRemark) { + java.math.BigDecimal netWeightKg, String cargoRemark, + String customerRemark, String latestInternalNote) { public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg, java.math.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, java.math.BigDecimal originalGrossWeightKg, + java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, java.math.BigDecimal netWeightKg, + String cargoRemark) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg, + originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg, + cargoRemark, null, null); } } @@ -113,4 +158,8 @@ public class InternalOrderCargoController { Integer packageCount, java.math.BigDecimal lengthCm, java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { } + + public record MeasurementDeclarationSnapshot( + String referenceId, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} } diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index f072f41e7..5968f9d0c 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantBatchDraftDtos; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.dto.MerchantInboundOrderRow; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; @@ -50,6 +51,12 @@ public class MerchantFirstLegOrderController { @Resource private MerchantBatchDraftService batchDraftService; + @GetMapping("/inbound-code-policy") + @PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')") + public ApiResponse inboundCodePolicy() { + return ApiResponse.success(service.inboundCodePolicy()); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿") @Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。") @ApiResponses({ @@ -69,7 +76,7 @@ public class MerchantFirstLegOrderController { @BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿") @Operation(summary = "Batch create drafts / 批量创建草稿", - description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。") + description = "每个数组元素代表一张草稿;单行失败不影响其它行。AUTO 模式的入仓码在提交订单时生成,草稿结果允许为空。") @PostMapping("/batch") @PreAuthorize("hasAuthority('merchant:order:create')") public ApiResponse batchCreate( diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 3c71451d7..bb2594cfc 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -8,6 +8,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.entity.OrderEntity; @@ -47,6 +48,12 @@ public class PlatformFirstLegOrderController { @Resource private OrderApplicationService service; + @GetMapping("/inbound-code-policy") + @PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')") + public ApiResponse inboundCodePolicy() { + return ApiResponse.success(service.inboundCodePolicy()); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿") @Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。") @ApiResponses({ diff --git a/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java b/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java new file mode 100644 index 000000000..d5e941742 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java @@ -0,0 +1,5 @@ +package com.baiyue.order.dto; + +/** 全系统统一的入仓码录入策略。 */ +public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) { +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 42cce323b..28db86533 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -28,7 +28,8 @@ public final class OrderRequests { @Schema(name = "FirstLegOrderSaveRequest", description = "First-leg order create or update request / 头程订单新增或更新请求") public record SaveRequest( - @Schema(description = "Customer ID / 客户 ID", example = "30001") Long customerId, + @Schema(description = "Deprecated and ignored. Customer ID is always taken from authenticated enterprise context / 已废弃并忽略,客户 ID 固定取认证企业上下文", deprecated = true) + Long customerId, @Schema(description = "Customer name / 客户名称", example = "Shenzhen Example Trading Co., Ltd. / 深圳示例贸易有限公司", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 128) String customerName, @Schema(description = "Transport mode / 运输方式", example = "SEA", allowableValues = {"SEA", "AIR", "RAIL", "TRUCK"}, requiredMode = Schema.RequiredMode.REQUIRED) @@ -52,6 +53,8 @@ public final class OrderRequests { @Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED) @NotEmpty @Valid List cargoes, @NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod, + @Schema(description = "Customer-requested pickup appointment; only applies to PICKUP / 客户要求平台上门提货的预约时间,仅 PICKUP 适用") + LocalDateTime requestedPickupAt, @NotNull Long receivingWarehouseId, @NotBlank @Size(max = 128) String receivingWarehouseName, @NotBlank @Size(max = 64) String channelCode, @@ -68,8 +71,44 @@ public final class OrderRequests { @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, @Size(max = 32) String amazonWarehouseCode, @Size(max = 64) String shipmentId, - @Size(max = 64) String referenceId + @Size(max = 64) String referenceId, + @Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40) + @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode ) { + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, + String destinationCountryCode, Long destinationWarehouseId, + String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, + Party sender, Party receiver, List cargoes, String pickupMethod, + LocalDateTime requestedPickupAt, Long receivingWarehouseId, + String receivingWarehouseName, String channelCode, String channelName, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, + String containerMode, Boolean customsInspection, String destinationType, + String amazonWarehouseCode, String shipmentId, String referenceId) { + this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode, + destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark, + sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, + receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, + pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, + shipmentId, referenceId, null); + } + + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, + String destinationCountryCode, Long destinationWarehouseId, + String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, + Party sender, Party receiver, List cargoes, String pickupMethod, + Long receivingWarehouseId, String receivingWarehouseName, String channelCode, + String channelName, Long carrierId, String carrierName, String taxMode, + String pricingTaxMode, String containerMode, Boolean customsInspection, + String destinationType, String amazonWarehouseCode, String shipmentId, + String referenceId) { + this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode, + destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark, + sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, + receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, + pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, + shipmentId, referenceId, null); + } + @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") public boolean isCarrierSnapshotComplete() { return (carrierId == null) == (carrierName == null || carrierName.isBlank()); diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 4ea88fa45..d1784d4cd 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -21,10 +21,19 @@ public final class UnifiedOrderResponse { Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, Integer totalPackageCount, String recommendedPalletType, String locationCodes, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + LocalDateTime expectedArrivalAt, String feeReviewStatus, + String companyName, String businessType, String pickupMethod, + String taxMode, String containerMode, Boolean customsInspection, + String destinationType, String destinationCode, + Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, + BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg, + LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt, + String expressCompany, String expressTrackingNo, String createdBy, + List skuNos, Integer totalQuantity, String shipmentId, String referenceId, MeasurementSummary measurement, PreloadSummary preload, LoadingSummary loading, DeliverySummary delivery, TransportSummary transport, String customerRemark, String latestInternalNote, - LocalDateTime createdAt, LocalDateTime updatedAt + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime requestedPickupAt ) {} public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId, @@ -42,9 +51,12 @@ public final class UnifiedOrderResponse { Integer expectedCount, Integer pickedCount, Integer evidenceCount, String sealNo, LocalDateTime updatedAt) {} public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} - public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo, - String vesselName, String voyageNo, LocalDateTime sailingAt, - LocalDateTime portArrivalAt) {} + public record TransportSummary(String containerNo, String cabinetNo, + String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime effectiveSailingAt, Boolean effectiveSailingAtEstimated, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, + LocalDateTime effectivePortArrivalAt, Boolean effectivePortArrivalAtEstimated) {} public record TimelineEvent(String source, String eventType, String mainStatus, String action, String remark, Long operatorId, String operatorName, LocalDateTime occurredAt) {} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 6f5de2a38..8a1e97d44 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -33,6 +33,7 @@ public class OrderEntity extends BaseEntity { private LocalDate expectedShipDate; private String customerRemark; private String pickupMethod; + private LocalDateTime requestedPickupAt; private Long receivingWarehouseId; private String receivingWarehouseName; private String receivingWarehouseAddress; diff --git a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java new file mode 100644 index 000000000..87443a9ac --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.order.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; + +@Mapper +public interface InboundCodeSequenceMapper { + @Insert(""" + INSERT INTO ord_inbound_code_daily_sequence + (business_date, current_value, created_at, updated_at) + VALUES + (#{businessDate}, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE + current_value = current_value + 1, + updated_at = CURRENT_TIMESTAMP(3) + """) + int increment(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT current_value FROM ord_inbound_code_daily_sequence WHERE business_date = #{businessDate}") + Long currentValue(@Param("businessDate") LocalDate businessDate); +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 142929cec..9734d6c5d 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; @@ -33,6 +35,17 @@ public class OrderRepository { @Resource private OutboxEventMapper outboxMapper; @Resource private OrderChildPurgeMapper childPurgeMapper; @Resource private OrderIdempotencyRecordMapper idempotencyMapper; + @Resource private InboundCodeSequenceMapper inboundCodeSequenceMapper; + + @Transactional(propagation = Propagation.MANDATORY) + public long nextInboundCodeSequence(java.time.LocalDate businessDate) { + if (businessDate == null || inboundCodeSequenceMapper.increment(businessDate) < 1) { + throw new IllegalStateException("Failed to allocate inbound code sequence"); + } + Long value = inboundCodeSequenceMapper.currentValue(businessDate); + if (value == null || value < 1) throw new IllegalStateException("Failed to read inbound code sequence"); + return value; + } public void insertOrder(OrderEntity order) { orderMapper.insert(order); } public int saveOrder(long tenantId, OrderEntity order) { @@ -108,6 +121,13 @@ public class OrderRepository { .eq(OrderEntity::getId, orderId)); } + public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) { + if (!StringUtils.hasText(inboundCode)) return false; + return orderMapper.selectCount(new LambdaQueryWrapper() + .eq(OrderEntity::getInboundCode, inboundCode) + .ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0; + } + public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, OrderEntity::getTenantId, tenantId) @@ -195,10 +215,10 @@ public class OrderRepository { return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList(); } - public List findCargoByOrderIds(long tenantId, List orderIds) { + public List findCargoByOrderIds(Long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return cargoMapper.selectList(new LambdaQueryWrapper() - .eq(OrderCargoEntity::getTenantId, tenantId) + .eq(tenantId != null, OrderCargoEntity::getTenantId, tenantId) .in(OrderCargoEntity::getOrderId, orderIds) .orderByAsc(OrderCargoEntity::getOrderId) .orderByAsc(OrderCargoEntity::getItemNo)); diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java index cfdd87a5a..29ac8acd3 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java @@ -3,7 +3,10 @@ package com.baiyue.order.service; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; +import jakarta.validation.Validator; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -13,9 +16,19 @@ import org.springframework.transaction.annotation.Transactional; public class MerchantBatchDraftItemService { @Resource private OrderApplicationService orders; @Resource private OrderRepository repository; + @Resource private Validator validator; @Transactional(propagation = Propagation.REQUIRES_NEW) public OrderEntity create(long tenantId, long userId, OrderRequests.SaveRequest draft, String idempotencyKey) { + var violations = validator.validate(draft); + if (!violations.isEmpty()) { + String message = violations.stream() + .sorted(java.util.Comparator.comparing(violation -> violation.getPropertyPath().toString())) + .findFirst() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .orElse("草稿参数不合法"); + throw new BizException(ResultCode.VALIDATION_FAILED, message); + } Long id = orders.createDraft(tenantId, userId, draft, idempotencyKey); return repository.findOwned(tenantId, id); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 19a5b558b..832ad9e86 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -6,9 +6,11 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.entity.*; import com.baiyue.order.repository.OrderRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -33,18 +35,27 @@ import java.time.*; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; @Service public class OrderApplicationService { + private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); @Resource private OrderRepository repository; @Resource private ObjectMapper json; @Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private ProductImageGuard productImageGuard; + @Resource private InboundCodeModeProvider inboundCodeModeProvider; @Resource private ObjectProvider firstlegProvider; @Value("${baiyue.security.internal-service-token:}") private String internalToken; + public InboundCodePolicyResponse inboundCodePolicy() { + boolean manual = legacyManualMode(); + return new InboundCodePolicyResponse(inboundCodeModeProvider.mode().name(), manual); + } + /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) { @@ -61,16 +72,21 @@ public class OrderApplicationService { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); + requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), + request.destinationType()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); - order.setInboundCode("RK-" + orderId); order.setStatus(OrderStatus.DRAFT); - apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId); - repository.insertOrder(order); + order.setStatus(OrderStatus.DRAFT); + apply(order, tenantId, request, snapshots); + order.setCreatedBy(userId); order.setUpdatedBy(userId); + try { + repository.insertOrder(order); + } catch (DuplicateKeyException duplicate) { + throw duplicateInboundCode(order, duplicate); + } saveChildren(tenantId, userId, order.getId(), request); record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); - prepareDraftInbound(order, request); return order.getId(); } @@ -100,19 +116,21 @@ public class OrderApplicationService { && !(allowSubmitted && before == OrderStatus.SUBMITTED)) { throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料"); } - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); - apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); - if (before == OrderStatus.REJECTED && !StringUtils.hasText(order.getInboundCode())) { - order.setInboundCode("RK-" + order.getId()); - } + requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), + request.destinationType()); + apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } - if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + try { + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + } catch (DuplicateKeyException duplicate) { + throw duplicateInboundCode(order, duplicate); + } repository.replaceDraftChildren(tenantId, id); saveChildren(tenantId, userId, id, request); record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null); - prepareDraftInbound(order, request); + if (before == OrderStatus.SUBMITTED) prepareDraftInbound(order, request); } @Transactional @@ -122,23 +140,28 @@ public class OrderApplicationService { OrderStatus from = order.getStatus(); if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target); + String inboundCodePrefix = null; if (target == OrderStatus.SUBMITTED) { validatePersistedPrototypeFields(tenantId, order); masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); - pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode()); + inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(), + order.getChannelCode(), order.getDestinationType()); } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { throw new BizException("Reason is required"); } order.setStatus(target); order.setUpdatedBy(userId); if (target == OrderStatus.SUBMITTED) { - if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId()); + if (!StringUtils.hasText(order.getInboundCode())) { + if (legacyManualMode()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码"); + } + order.setInboundCode(nextInboundCode(inboundCodePrefix)); + } + requirePrintableInboundCode(order); order.setSubmittedAt(LocalDateTime.now()); } - if (target == OrderStatus.DRAFT && !StringUtils.hasText(order.getInboundCode())) { - order.setInboundCode("RK-" + order.getId()); - } if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); @@ -159,6 +182,21 @@ public class OrderApplicationService { public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } + String nextInboundCode(String prefix) { + LocalDate businessDate = LocalDate.now(INBOUND_CODE_ZONE); + return formatInboundCode(prefix, businessDate, repository.nextInboundCodeSequence(businessDate)); + } + + static String formatInboundCode(String prefix, LocalDate date, long sequence) { + if (!StringUtils.hasText(prefix) || !prefix.matches("[A-Z]{4,6}")) { + throw new BizException(ResultCode.CONFLICT, "渠道入仓码前缀配置无效"); + } + if (date == null || sequence < 1) { + throw new BizException(ResultCode.CONFLICT, "入仓码票序生成失败"); + } + return prefix + date.format(INBOUND_CODE_DATE) + String.format(Locale.ROOT, "%04d", sequence); + } + public List countByTenantIds(List tenantIds) { var counts = repository.countByTenantIds(tenantIds); return tenantIds.stream().distinct() @@ -270,6 +308,9 @@ public class OrderApplicationService { if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) { throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息"); } + if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间"); + } if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) { throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息"); } @@ -393,9 +434,10 @@ public class OrderApplicationService { default -> false; }; } - private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) { - order.setCustomerId(snapshots.partner() == null ? null : snapshots.partner().id()); - order.setCustomerName(snapshots.partner() == null ? request.customerName() : snapshots.partner().partnerName()); + private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) { + order.setCustomerId(tenantId); + applyInboundCode(order, request.inboundCode()); + order.setCustomerName(request.customerName().trim()); order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm()); order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode() : snapshots.destination().warehouse().countryCode()); @@ -403,7 +445,9 @@ public class OrderApplicationService { order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName() : snapshots.destination().warehouse().warehouseName()); order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark()); - order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId()); + order.setPickupMethod(request.pickupMethod()); + order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null); + order.setReceivingWarehouseId(request.receivingWarehouseId()); order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName()); order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving())); order.setReceivingTime(formatReceivingTime(snapshots.receiving())); @@ -422,6 +466,54 @@ public class OrderApplicationService { ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); } + private void applyInboundCode(OrderEntity order, String requestedCode) { + String normalized = StringUtils.hasText(requestedCode) + ? requestedCode.trim().toUpperCase(Locale.ROOT) : null; + if (!legacyManualMode()) { + if (normalized != null && !normalized.equals(order.getInboundCode())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写"); + } + return; + } + if (order.getStatus() != OrderStatus.DRAFT && StringUtils.hasText(order.getInboundCode())) { + if (normalized != null && !normalized.equals(order.getInboundCode())) { + throw new BizException(ResultCode.CONFLICT, "入仓码提交后不可修改"); + } + return; + } + if (normalized != null && repository.inboundCodeExists(normalized, order.getId())) { + throw new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据"); + } + order.setInboundCode(normalized); + } + private boolean legacyManualMode() { + return inboundCodeModeProvider.legacyManual(); + } + private String requirePricingChannel(long tenantId, long receivingWarehouseId, String channelCode, + String destinationType) { + if (legacyManualMode()) { + pricingChannelGuard.requireActive(tenantId, receivingWarehouseId, channelCode, destinationType); + return null; + } + return pricingChannelGuard.requireAvailable(tenantId, receivingWarehouseId, channelCode, destinationType); + } + private RuntimeException duplicateInboundCode(OrderEntity order, DuplicateKeyException duplicate) { + if (StringUtils.hasText(order.getInboundCode())) { + return new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据"); + } + return duplicate; + } + private void requirePrintableInboundCode(OrderEntity order) { + String inboundCode = order.getInboundCode(); + if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) { + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码格式不正确"); + } + int total = totalPackageCount(order); + String longestCartonCode = inboundCode + "#" + total + "-" + total; + if (longestCartonCode.codePointCount(0, longestCartonCode.length()) > 34) { + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码过长,无法生成箱唛二维码"); + } + } private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) { if (detail.addresses() == null) return null; var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null); @@ -506,7 +598,8 @@ public class OrderApplicationService { order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), - order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount); + order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount, + order.getPickupMethod(), order.getRequestedPickupAt()); } private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index a46cf63b6..524868b10 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -141,11 +141,21 @@ public class OrderFulfillmentSplitService { child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId()); child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode()); child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType()); + child.setInboundCode(remainderInboundCode(root, sequence)); child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId); if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败"); return child; } + private String remainderInboundCode(OrderEntity root, int sequence) { + String rootInboundCode = Optional.ofNullable(root.getInboundCode()).orElse("").trim(); + String childInboundCode = rootInboundCode + "-" + sequence; + if (rootInboundCode.isEmpty() || sequence < 1 || childInboundCode.length() > 40) { + throw new BizException(ResultCode.CONFLICT, "根订单入仓码格式无效,无法创建余量子订单"); + } + return childInboundCode; + } + private OrderFulfillmentSplitEntity ledger(OrderSplitDtos.CreateRequest r, OrderEntity root, OrderEntity source, long childId, String role, String hash, long operatorId) { OrderFulfillmentSplitEntity row = new OrderFulfillmentSplitEntity(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 3840cb691..f2bf8af87 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -5,7 +5,6 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; -import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.dto.OrderRequests; import feign.FeignException; @@ -21,13 +20,11 @@ import org.slf4j.LoggerFactory; public class OrderMasterDataGuard { private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class); @Resource private WarehouseClient warehouses; - @Resource private PartnerClient partners; @Value("${baiyue.security.internal-service-token}") private String token; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicTenantId = 0L; public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) { try { - PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(request.customerId()); var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库"); requireWarehouseType(receiving, "DOMESTIC", "收货仓库"); if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull)) @@ -47,7 +44,7 @@ public class OrderMasterDataGuard { destination=loadWarehouse(request.destinationWarehouseId(),"目的仓库"); requireWarehouseType(destination, request.destinationType(), "目的仓库"); } - return new Snapshots(partner,receiving,destination); + return new Snapshots(receiving,destination); } catch (FeignException exception) { throw downstreamFailure(exception); } catch (BizException exception) { @@ -56,10 +53,12 @@ public class OrderMasterDataGuard { throw downstreamFailure(exception); } } - public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, + public void validatePersisted(long tenantId, Long customerId, Long receivingWarehouseId, Long destinationWarehouseId, String destinationType) { try { - if (partnerId!=null) loadPartner(partnerId); + if (customerId != null && customerId != tenantId) { + throw new BizException(ResultCode.FORBIDDEN,"订单客户必须是当前登录企业"); + } if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用"); WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库"); requireWarehouseType(receiving,"DOMESTIC","收货仓库"); @@ -78,25 +77,6 @@ public class OrderMasterDataGuard { throw downstreamFailure(exception); } } - private PartnerClient.Partner loadPartner(long id) { - ApiResponse response; - try { - response=partners.active(token,publicTenantId,id); - } catch (FeignException.NotFound exception) { - throw new BizException(ResultCode.NOT_FOUND,"平台往来单位不存在或不可用"); - } - requireSuccessful(response,"往来单位"); - if (response.data()==null) throw unavailable("往来单位"); - var value=response.data(); - if (!Long.valueOf(publicTenantId).equals(value.tenantId())) - throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于平台公共主数据"); - if (!"ACTIVE".equals(value.status())) - throw new BizException(ResultCode.CONFLICT,"往来单位已停用"); - if (value.types() == null || !value.types().contains("SHIPPER")) - throw new BizException(ResultCode.VALIDATION_FAILED,"所选往来单位不具备发货单位类型"); - if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位"); - return value; - } private WarehouseClient.Detail loadWarehouse(long id,String name) { ApiResponse response; try { @@ -139,6 +119,5 @@ public class OrderMasterDataGuard { throw new BizException(code, message); } } - public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving, - WarehouseClient.Detail destination) { } + public record Snapshots(WarehouseClient.Detail receiving, WarehouseClient.Detail destination) { } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java index 4ccf3ed7c..8a901f1b3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -14,10 +14,22 @@ public class OrderPricingChannelGuard { @Resource private FirstlegPricingClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; - public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) { + public String requireAvailable(long tenantId, long originWarehouseId, String channelCode, + String destinationType) { + return validate(tenantId, originWarehouseId, channelCode, destinationType, true); + } + + public void requireActive(long tenantId, long originWarehouseId, String channelCode, + String destinationType) { + validate(tenantId, originWarehouseId, channelCode, destinationType, false); + } + + private String validate(long tenantId, long originWarehouseId, String channelCode, + String destinationType, boolean requireInboundCodePrefix) { try { - ApiResponse response = client.validateChannel(internalToken, - new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode)); + ApiResponse response = client.validateChannel(internalToken, + new FirstlegPricingClient.ChannelValidationRequest( + tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix)); if (response == null) { throw unavailable(); } @@ -27,6 +39,11 @@ public class OrderPricingChannelGuard { ? code.message() : response.message(); throw new BizException(code, message); } + if (response.data() == null || requireInboundCodePrefix + && (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) { + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); + } + return response.data().inboundCodePrefix(); } catch (BizException exception) { throw exception; } catch (FeignException exception) { diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 8d98938c1..5e3401d97 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; @@ -19,10 +20,12 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -56,9 +59,12 @@ public class UnifiedOrderQueryService { ? Map.of() : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map cargoSummaries = cargoSummaries(tenantId, + source.records().stream().map(OrderEntity::getId).toList()); Map childCounts = childCounts(source.records()); List rows = source.records().stream() - .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts)).toList(); + .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), + childCounts, cargoSummaries.get(order.getId()))).toList(); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); } @@ -72,7 +78,7 @@ public class UnifiedOrderQueryService { ? null : firstlegDetail(tenantId, orderId); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote, - childCounts(List.of(order))); + childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes())); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), @@ -106,10 +112,12 @@ public class UnifiedOrderQueryService { Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map latestNotes = latestNotes(tenantId, orderIds); + Map cargoSummaries = cargoSummaries(tenantId, orderIds); Map childCounts = childCounts(List.copyOf(orderById.values())); List records = source.records().stream() .map(summary -> row(summary, orderById.get(summary.inbound().orderId()), - latestNotes.get(summary.inbound().orderId()), childCounts)) + latestNotes.get(summary.inbound().orderId()), childCounts, + cargoSummaries.get(summary.inbound().orderId()))) .toList(); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); } @@ -134,6 +142,13 @@ public class UnifiedOrderQueryService { OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest)); } + private Map cargoSummaries(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return Map.of(); + return orders.findCargoByOrderIds(tenantId, orderIds).stream() + .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId, + Collectors.collectingAndThen(Collectors.toList(), CargoSummary::from))); + } + private Map childCounts(List pageOrders) { if (pageOrders.isEmpty()) return Map.of(); return orders.findChildrenByParentOrders(pageOrders).stream() @@ -155,7 +170,8 @@ public class UnifiedOrderQueryService { } private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, - String latestInternalNote, Map childCounts) { + String latestInternalNote, Map childCounts, + CargoSummary cargo) { if (summary == null) { String mainStatus = orderMainStatus(order.getStatus()); return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, @@ -165,9 +181,14 @@ public class UnifiedOrderQueryService { order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), null, null, null, null, null, null, null, null, null, null, + order.getExpectedShipDate(), totalPackageCount(cargo), null, null, null, + null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), + order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), + null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), + null, null, null, null, null, order.getCustomerRemark(), latestInternalNote, - order.getCreatedAt(), order.getUpdatedAt()); + order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt()); } FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), @@ -175,15 +196,21 @@ public class UnifiedOrderQueryService { order.getCustomerId(), order.getCustomerName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), - order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound), + order.getExpectedShipDate(), inbound.totalPackageCount(), null, inbound.locationCodes(), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), + inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), + inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote, - order.getCreatedAt(), order.getUpdatedAt()); + order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt()); } private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, - String latestInternalNote, Map childCounts) { + String latestInternalNote, Map childCounts, + CargoSummary cargo) { var inbound = summary.inbound(); return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), @@ -191,10 +218,16 @@ public class UnifiedOrderQueryService { inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), - "STANDARD", inbound.locationCodes(), inbound.actualGrossWeightKg(), - inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + null, inbound.locationCodes(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), + inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), + inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(), + order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote, - inbound.createdAt(), inbound.updatedAt()); + inbound.createdAt(), inbound.updatedAt(), inbound.requestedPickupAt()); } private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) { @@ -216,8 +249,15 @@ public class UnifiedOrderQueryService { value.status(), value.completedAt(), value.trackingNo()); } private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) { - return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(), - inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt()); + LocalDateTime sailingAt = inbound.actualSailingAt() != null + ? inbound.actualSailingAt() : inbound.estimatedSailingAt(); + LocalDateTime arrivalAt = inbound.actualPortArrivalAt() != null + ? inbound.actualPortArrivalAt() : inbound.estimatedPortArrivalAt(); + return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(), + inbound.vesselName(), inbound.voyageNo(), inbound.estimatedSailingAt(), inbound.actualSailingAt(), + sailingAt, sailingAt == null ? null : inbound.actualSailingAt() == null, + inbound.estimatedPortArrivalAt(), inbound.actualPortArrivalAt(), arrivalAt, + arrivalAt == null ? null : inbound.actualPortArrivalAt() == null); } private String orderMainStatus(OrderStatus status) { @@ -225,6 +265,36 @@ public class UnifiedOrderQueryService { return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; } + + private String string(Long value) { + return value == null ? null : value.toString(); + } + + private List skuNos(CargoSummary cargo) { + return cargo == null || cargo.skuNos().isEmpty() ? null : cargo.skuNos(); + } + + private Integer totalQuantity(CargoSummary cargo) { + return cargo == null ? null : cargo.totalQuantity(); + } + + private Integer totalPackageCount(CargoSummary cargo) { + return cargo == null ? null : cargo.totalPackageCount(); + } + + private record CargoSummary(List skuNos, Integer totalQuantity, Integer totalPackageCount) { + private static CargoSummary from(List cargoes) { + List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo) + .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); + int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + List packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount) + .filter(Objects::nonNull).toList(); + Integer totalPackageCount = packageCounts.isEmpty() ? null + : Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum()); + return new CargoSummary(skuNos, totalQuantity, totalPackageCount); + } + } private String inboundMainStatus(String status) { if (status == null) return null; return switch (status) { diff --git a/order/src/main/resources/application-test.yml b/order/src/main/resources/application-test.yml index 5f6be57fc..8e0bc7e3b 100644 --- a/order/src/main/resources/application-test.yml +++ b/order/src/main/resources/application-test.yml @@ -13,3 +13,5 @@ spring: url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} + +baiyue.order.inbound-code-mode: AUTO diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index 19677f6c6..5674c27d8 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -35,7 +35,8 @@ baiyue: url: ${WAREHOUSE_SERVICE_URL:} file: url: ${FILE_SERVICE_URL:} - + order: + inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} logging: file: name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 3bec81ee9..d61276e08 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', + inbound_code VARCHAR(40) NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( expected_ship_date DATE NULL COMMENT '预计发运日期', customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', pickup_method VARCHAR(24) NULL COMMENT '取货方式', + requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间', receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID', receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照', receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照', @@ -53,13 +54,75 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), - UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), + UNIQUE KEY uk_order_inbound_code (inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +-- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。 +SET @order_global_inbound_index_sql = IF( + NOT EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_inbound_code' + ), + 'ALTER TABLE baiyue_order.ord_order ADD UNIQUE KEY uk_order_inbound_code (inbound_code)', + 'SELECT 1' +); +PREPARE order_global_inbound_index_stmt FROM @order_global_inbound_index_sql; +EXECUTE order_global_inbound_index_stmt; +DEALLOCATE PREPARE order_global_inbound_index_stmt; + +SET @order_tenant_inbound_index_drop_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' + ), + 'ALTER TABLE baiyue_order.ord_order DROP INDEX uk_order_tenant_inbound_code', + 'SELECT 1' +); +PREPARE order_tenant_inbound_index_drop_stmt FROM @order_tenant_inbound_index_drop_sql; +EXECUTE order_tenant_inbound_index_drop_stmt; +DEALLOCATE PREPARE order_tenant_inbound_index_drop_stmt; + +-- Existing development databases may still have the former NOT NULL contract. +SET @order_inbound_code_nullable_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'inbound_code' AND is_nullable = 'NO' + ), + 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定''', + 'SELECT 1' +); +PREPARE order_inbound_code_nullable_stmt FROM @order_inbound_code_nullable_sql; +EXECUTE order_inbound_code_nullable_stmt; +DEALLOCATE PREPARE order_inbound_code_nullable_stmt; + +SET @order_requested_pickup_at_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'requested_pickup_at' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL COMMENT ''客户要求平台上门提货的预约时间'' AFTER pickup_method' +); +PREPARE order_requested_pickup_at_stmt FROM @order_requested_pickup_at_sql; +EXECUTE order_requested_pickup_at_stmt; +DEALLOCATE PREPARE order_requested_pickup_at_stmt; + +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT 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 (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; + CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f32a3380a..9891be3ce 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,12 +6,28 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/orders/first-leg/inbound-code-policy: + get: + summary: 平台端查询全系统入仓码模式 + description: 与客户下单端返回同一份全局策略,不按客户区分。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + data: {$ref: '#/components/schemas/InboundCodePolicy'} + '403': {description: 需要平台订单列表、创建、编辑或提交权限之一} /api/platform/v1/orders: get: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回 + 入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序; + LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, @@ -52,7 +68,7 @@ paths: /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 - description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -197,6 +213,23 @@ paths: schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'} '400': {description: 请求数组为空、超过 50 项或幂等键缺失} '403': {description: 需要 merchant:order:create 权限} + /api/merchant/v1/orders/first-leg/inbound-code-policy: + get: + summary: 查询全系统入仓码模式 + description: >- + 返回订单服务当前统一运行模式。AUTO 表示提交时由新系统生成;LEGACY_MANUAL + 表示两套系统并跑期间必须录入旧系统生成的入仓码。该策略不按客户区分。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + data: {$ref: '#/components/schemas/InboundCodePolicy'} + '403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一} /api/merchant/v1/orders/first-leg/{id}/submit: post: summary: 提交订单 @@ -423,11 +456,36 @@ components: inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} inboundCode: {type: string, nullable: true} expectedShipDate: {type: string, format: date, nullable: true} - totalPackageCount: {type: integer, nullable: true} - recommendedPalletType: {type: string, nullable: true} + totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} + recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退} actualVolumeCbm: {type: number, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} + companyName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + pickupMethod: {type: string, nullable: true} + requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot} + taxMode: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + totalPalletCount: {type: integer, nullable: true} + chargeableWeightKg: {type: number, nullable: true, description: Firstleg confirmed billing snapshot chargeable weight in kilograms} + chargeableVolumeCbm: {type: number, nullable: true} + volumetricWeightKg: {type: number, nullable: true} + totalNetWeightKg: {type: number, nullable: true, description: 统一订单货物净重展示字段;严格来源 Firstleg actualNetWeightKg,不以毛重回退} + measurementCompletedAt: {type: string, format: date-time, nullable: true} + inboundCompletedAt: {type: string, format: date-time, nullable: true} + expressCompany: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + skuNos: {type: array, nullable: true, items: {type: string}} + totalQuantity: {type: integer, nullable: true} + shipmentId: {type: string, nullable: true} + referenceId: {type: string, nullable: true} measurement: {$ref: '#/components/schemas/UnifiedMeasurementSummary'} preload: {$ref: '#/components/schemas/UnifiedPreloadSummary'} loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'} @@ -451,7 +509,7 @@ components: nullable: true properties: reviewStatus: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;不得使用净重回退} actualVolumeCbm: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true} completedAt: {type: string, format: date-time, nullable: true} @@ -486,13 +544,18 @@ components: type: object nullable: true properties: - loadingStatus: {type: string, nullable: true} containerNo: {type: string, nullable: true} cabinetNo: {type: string, nullable: true} vesselName: {type: string, nullable: true} voyageNo: {type: string, nullable: true} - sailingAt: {type: string, format: date-time, nullable: true} - portArrivalAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间} + actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间} + effectiveSailingAt: {type: string, format: date-time, nullable: true, description: 实际开船时间优先,否则预计开船时间} + effectiveSailingAtEstimated: {type: boolean, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间} + actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间} + effectivePortArrivalAt: {type: string, format: date-time, nullable: true, description: 实际到港时间优先,否则预计到港时间} + effectivePortArrivalAtEstimated: {type: boolean, nullable: true} UnifiedOrderTimelineEvent: type: object properties: @@ -583,6 +646,11 @@ components: type: object required: [pricingTaxMode, cargoes] properties: + inboundCode: + type: string + maxLength: 40 + pattern: '^[A-Za-z0-9._:-]+$' + description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略 pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} cargoes: type: array @@ -590,7 +658,14 @@ components: items: {$ref: '#/components/schemas/FirstLegOrderCargo'} description: >- 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode - 是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。 + 是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID; + 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。 + InboundCodePolicy: + type: object + required: [mode, manualEntryRequired] + properties: + mode: {type: string, enum: [AUTO, LEGACY_MANUAL]} + manualEntryRequired: {type: boolean} FirstLegBatchDraftItem: type: object required: [clientRowId, draft] @@ -620,7 +695,7 @@ components: success: {type: boolean} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} - inboundCode: {type: string, pattern: '^RK-[A-Za-z0-9-]+$'} + inboundCode: {type: string, pattern: '^[A-Za-z0-9._:-]+$', example: ZBYHK2608140001, description: AUTO 模式为渠道前缀+业务日期+全局票序;LEGACY_MANUAL 模式为录入的旧系统入仓码;履约拆分的子订单追加 splitSequence 后缀} errorCode: {type: string} errorMessage: {type: string} FirstLegOrderCargo: @@ -721,7 +796,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] UnifiedInboundCode: - {name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}} + {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: name: internalOrderStatus in: query diff --git a/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java b/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java index 012df84dc..45ab0e4a5 100644 --- a/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java +++ b/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java @@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; class WarehouseFeignContractTest { @Test void clientsUseNacosServiceNameAndInternalPathsByDefault() throws Exception { assertClient(WarehouseClient.class, "/internal/warehouses"); - assertClient(PartnerClient.class, "/internal/warehouse-partners"); } private void assertClient(Class type, String expectedPath) throws Exception { diff --git a/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java b/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java new file mode 100644 index 000000000..07daa70cb --- /dev/null +++ b/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java @@ -0,0 +1,53 @@ +package com.baiyue.order.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InboundCodeModeProviderTest { + @Test + void springPlaceholderDefaultsToAutoWhenEnvironmentPropertyIsMissing() { + Constructor constructor = InboundCodeModeProvider.class.getDeclaredConstructors()[0]; + Value value = constructor.getParameters()[0].getAnnotation(Value.class); + + assertThat(value.value()) + .isEqualTo("${baiyue.order.inbound-code-mode:AUTO}"); + assertThat(new InboundCodeModeProvider("AUTO").mode()) + .isEqualTo(InboundCodeModeProvider.Mode.AUTO); + } + + @Test + void applicationConfigurationSupportsOrderModeEnvironmentVariableWithoutNacos() throws IOException { + String applicationYaml; + try (var stream = InboundCodeModeProviderTest.class.getResourceAsStream("/application.yml")) { + assertThat(stream).isNotNull(); + applicationYaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(applicationYaml) + .contains("inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}"); + } + + @Test + void parsesBothSupportedModesCaseInsensitively() { + assertThat(new InboundCodeModeProvider("auto").mode()) + .isEqualTo(InboundCodeModeProvider.Mode.AUTO); + assertThat(new InboundCodeModeProvider(" legacy_manual ").legacyManual()).isTrue(); + } + + @Test + void rejectsMissingAndUnknownModesInsteadOfFallingBackToAutomaticGeneration() { + assertThatThrownBy(() -> new InboundCodeModeProvider(" ")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("baiyue.order.inbound-code-mode 必须显式配置"); + assertThatThrownBy(() -> new InboundCodeModeProvider("LEGACY-MANUAL")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java new file mode 100644 index 000000000..a805097ef --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.order.controller; + +import com.baiyue.order.dto.InboundCodePolicyResponse; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodePolicyContractTest { + @Test + void exposesOneGlobalPolicyToOrderReadersAndEditors() throws Exception { + var method = MerchantFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy"); + var authorization = method.getAnnotation(PreAuthorize.class); + + assertThat(authorization).isNotNull(); + assertThat(authorization.value()) + .isEqualTo("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')"); + assertThat(method.getGenericReturnType().getTypeName()) + .contains(InboundCodePolicyResponse.class.getName()); + + var platformMethod = PlatformFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy"); + assertThat(platformMethod.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')"); + assertThat(platformMethod.getGenericReturnType().getTypeName()) + .contains(InboundCodePolicyResponse.class.getName()); + } + + @Test + void documentsAutomaticAndLegacyManualModes() throws Exception { + String openapi = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(openapi) + .contains("/api/merchant/v1/orders/first-leg/inbound-code-policy:") + .contains("/api/platform/v1/orders/first-leg/inbound-code-policy:") + .contains("enum: [AUTO, LEGACY_MANUAL]") + .contains("manualEntryRequired: {type: boolean}") + .contains("该策略不按客户区分") + .contains("仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 630fe41e7..97be8050c 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -18,8 +19,11 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.math.BigDecimal; +import java.lang.reflect.Method; import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; + @SpringJUnitConfig(InternalOrderCargoController.class) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { @@ -41,6 +45,10 @@ class InternalOrderCargoControllerTest { cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); cargo.setCargoRemark("内部货物备注"); when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo); + OrderEntity order = new OrderEntity(); order.setId(21L); order.setCustomerRemark("客户订单备注"); + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); note.setContent("最新内部备注"); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findNotes(11L, 21L)).thenReturn(List.of(note)); var response = controller.detail("internal-token", 11L, 21L, 31L); @@ -53,6 +61,8 @@ class InternalOrderCargoControllerTest { assertThat(response.data().totalQuantity()).isEqualTo(200); assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); + assertThat(response.data().customerRemark()).isEqualTo("客户订单备注"); + assertThat(response.data().latestInternalNote()).isEqualTo("最新内部备注"); verify(orders).findCargoInheritedByOrder(11L, 21L, 31L); } @@ -96,6 +106,9 @@ class InternalOrderCargoControllerTest { OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); cargo.setOrderId(21L); when(orders.findCargoInheritedByOrder(11L, 201L, 31L)).thenReturn(cargo); + OrderEntity child = new OrderEntity(); child.setId(201L); + when(orders.findOwned(11L, 201L)).thenReturn(child); + when(orders.findNotes(11L, 201L)).thenReturn(List.of()); var response = controller.detail("internal-token", 11L, 201L, 31L); @@ -128,6 +141,57 @@ class InternalOrderCargoControllerTest { verify(orders).findOwned(11L, 21L); } + @Test + void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setTenantId(11L); order.setReferenceId("REF-50"); + OrderCargoEntity first = cargo(31L, 1, 60, "60", "40", "30"); + first.setCargoName("品名A"); first.setGrossWeightKg(new BigDecimal("1200")); + first.setVolumeCbm(new BigDecimal("12.5")); + OrderCargoEntity second = cargo(32L, 2, 40, "50", "30", "20"); + second.setCargoName("品名B"); second.setGrossWeightKg(new BigDecimal("800")); + second.setVolumeCbm(new BigDecimal("7.5")); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second)); + + var result = controller.measurementDeclaration("internal-token", 11L, 21L).data(); + + assertThat(result.referenceId()).isEqualTo("REF-50"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20.0"); + assertThat(result.defaultActualGoodsName()).isEqualTo("品名A、品名B"); + } + + @Test + void measurementDeclarationHasAnExplicitStaticRouteBesideNumericCargoDetail() throws Exception { + Method declaration = InternalOrderCargoController.class.getMethod( + "measurementDeclaration", String.class, long.class, long.class); + Method cargoDetail = InternalOrderCargoController.class.getMethod( + "detail", String.class, long.class, long.class, long.class); + + assertThat(declaration.getAnnotation(GetMapping.class).value()) + .containsExactly("/{tenantId}/{orderId}/measurement-declaration"); + assertThat(cargoDetail.getAnnotation(GetMapping.class).value()) + .containsExactly("/{tenantId}/{orderId}/{cargoId}"); + assertThat(declaration.getParameterCount()).isEqualTo(3); + assertThat(cargoDetail.getParameterCount()).isEqualTo(4); + } + + @Test + void splitChildWithoutDirectCargoDoesNotCopyRootDeclarationTotals() { + OrderEntity child = new OrderEntity(); + child.setId(201L); child.setTenantId(11L); child.setOrderRole("CHILD"); child.setRootOrderId(21L); + when(orders.findOwned(11L, 201L)).thenReturn(child); + when(orders.findCargo(11L, 201L)).thenReturn(List.of()); + + var result = controller.measurementDeclaration("internal-token", 11L, 201L).data(); + + assertThat(result.originalGrossWeightKg()).isNull(); + assertThat(result.originalVolumeCbm()).isNull(); + assertThat(result.defaultActualGoodsName()).isEmpty(); + verify(orders).findCargo(11L, 201L); + } + private OrderCargoEntity cargo(long id, int itemNo, int packageCount, String length, String width, String height) { OrderCargoEntity cargo = new OrderCargoEntity(); diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 24b688cbe..563303a4d 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -52,7 +52,12 @@ class UnifiedOrderApiContractTest { Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( "orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload", - "loading", "delivery", "transport", "customerRemark", "latestInternalNote"); + "loading", "delivery", "transport", "customerRemark", "latestInternalNote", + "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", + "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", + "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", + "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", + "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId"); assertThat(responseSchema(paths, "/api/platform/v1/orders", "get")) .containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope"); assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get")) diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index 1cbe646e3..e269eacce 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -54,6 +54,32 @@ class OrderRequestsValidationTest { assertThat(validator.validate(draft)).isEmpty(); } + @Test + void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() { + var source = request(null, null, "TAX_INCLUDED"); + var invalid = withInboundCode(source, "INVALID#CODE"); + var tooLong = withInboundCode(source, "A".repeat(41)); + var valid = withInboundCode(source, "NYEXX2608140001"); + + assertThat(validator.validate(invalid)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode")); + assertThat(validator.validate(tooLong)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode")); + assertThat(validator.validate(valid)).isEmpty(); + } + + private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) { + return new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), inboundCode); + } + private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) { var cargo = new OrderRequests.Cargo( "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java new file mode 100644 index 000000000..fd63e5e59 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.order.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeGlobalUniquenessContractTest { + @Test + void orderSchemaUsesOnePlatformWideInboundCodeIndex() throws Exception { + String sql = new ClassPathResource("db/order.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("UNIQUE KEY uk_order_inbound_code (inbound_code)") + .doesNotContain("UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)"); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java new file mode 100644 index 000000000..e4f722ba5 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCodeSequenceRepositoryTest { + @Mock private InboundCodeSequenceMapper mapper; + + @Test + void incrementsAndReadsTheCounterForTheSameShanghaiBusinessDate() { + LocalDate businessDate = LocalDate.of(2026, 8, 14); + OrderRepository repository = new OrderRepository(); + ReflectionTestUtils.setField(repository, "inboundCodeSequenceMapper", mapper); + when(mapper.increment(businessDate)).thenReturn(2); + when(mapper.currentValue(businessDate)).thenReturn(2L); + + assertThat(repository.nextInboundCodeSequence(businessDate)).isEqualTo(2L); + + InOrder ordered = inOrder(mapper); + ordered.verify(mapper).increment(businessDate); + ordered.verify(mapper).currentValue(businessDate); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java new file mode 100644 index 000000000..2daa67983 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java @@ -0,0 +1,59 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeSequenceSqlContractTest { + @Test + void mapperAtomicallyIncrementsOneCounterPerBusinessDate() throws Exception { + Method increment = InboundCodeSequenceMapper.class.getMethod("increment", LocalDate.class); + String incrementSql = normalize(String.join(" ", increment.getAnnotation(Insert.class).value())); + Method current = InboundCodeSequenceMapper.class.getMethod("currentValue", LocalDate.class); + String currentSql = normalize(String.join(" ", current.getAnnotation(Select.class).value())); + + assertThat(incrementSql) + .contains("business_date, current_value") + .contains("values (#{businessdate}, 1") + .contains("on duplicate key update") + .contains("current_value = current_value + 1"); + assertThat(currentSql) + .contains("where business_date = #{businessdate}") + .doesNotContain("channel_code") + .doesNotContain("tenant_id"); + } + + @Test + void baselineAndUnifiedSqlDefineTheShanghaiDailyCounter() throws Exception { + String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(resolveUnified(), StandardCharsets.UTF_8); + + for (String sql : java.util.List.of(baseline, unified)) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence") + .contains("business_date DATE NOT NULL") + .contains("current_value BIGINT UNSIGNED NOT NULL") + .contains("PRIMARY KEY (business_date)") + .contains("按上海日期重新从1开始"); + } + } + + private static String normalize(String sql) { + return sql.replaceAll("\\s+", " ").trim().toLowerCase(); + } + + private static Path resolveUnified() { + Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java index c58744782..e82c22c66 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -38,6 +39,7 @@ class MerchantInboundOrderQueryRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java new file mode 100644 index 000000000..88ada5c72 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.order.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderPickupSqlContractTest { + @Test + void moduleSchemaSupportsDraftWithoutInboundCodeAndUpgradesExistingTablesIdempotently() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/order.sql")); + assertThat(sql).contains( + "inbound_code VARCHAR(40) NULL", + "column_name = 'inbound_code' AND is_nullable = 'NO'", + "ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL", + "column_name = 'requested_pickup_at'", + "ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL"); + } + + @Test + void unifiedScriptRelaxesInboundCodeBeforeClearingDraftsAndAddsPickupColumn() throws Exception { + Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(path); + int relax = sql.indexOf("ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL"); + int clearDraft = sql.indexOf("SET inbound_code = NULL"); + assertThat(relax).isGreaterThanOrEqualTo(0).isLessThan(clearDraft); + assertThat(sql).contains( + "column_name = 'requested_pickup_at'", + "ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL", + "CREATE PROCEDURE assert_submitted_order_inbound_code()", + "SIGNAL SQLSTATE '45000'"); + assertThat(sql).doesNotContain("CONCAT('RK-', o.id)"); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java index 3fac52237..8aea2d775 100644 --- a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -34,6 +35,7 @@ class PlatformOrderPageRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java new file mode 100644 index 000000000..4192791b0 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.repository.OrderRepository; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Path; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantBatchDraftItemServiceTest { + @Test + void rejectsAnInvalidBatchRowBeforeCreatingTheDraft() { + OrderApplicationService orders = mock(OrderApplicationService.class); + OrderRepository repository = mock(OrderRepository.class); + Validator validator = mock(Validator.class); + @SuppressWarnings("unchecked") + ConstraintViolation violation = mock(ConstraintViolation.class); + Path path = mock(Path.class); + OrderRequests.SaveRequest draft = mock(OrderRequests.SaveRequest.class); + when(path.toString()).thenReturn("inboundCode"); + when(violation.getPropertyPath()).thenReturn(path); + when(violation.getMessage()).thenReturn("格式不正确"); + when(validator.validate(draft)).thenReturn(Set.of(violation)); + MerchantBatchDraftItemService service = new MerchantBatchDraftItemService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "validator", validator); + + assertThatThrownBy(() -> service.create(1L, 2L, draft, "batch-key")) + .isInstanceOf(BizException.class) + .hasMessage("inboundCode: 格式不正确"); + + verify(orders, never()).createDraft(1L, 2L, draft, "batch-key"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 44c4bdc11..636f7649a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -2,8 +2,8 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.WarehouseClient; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderCargoEntity; @@ -22,6 +22,8 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalTime; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.*; @@ -36,11 +38,12 @@ class OrderApplicationServiceStateTest { @MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @MockBean private ProductImageGuard contextProductImageGuard; + @MockBean private InboundCodeModeProvider contextInboundCodeModeProvider; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) static class TestConfig { - @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } } @Test void unapprovedTenantCanCreateDraft() { @@ -57,7 +60,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && ("RK-" + order.getId()).equals(order.getInboundCode()))); + && order.getInboundCode() == null)); } @Test @@ -105,13 +108,14 @@ class OrderApplicationServiceStateTest { OrderRequests.SaveRequest request = request(); when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(pricing.requireAvailable(10001L, 8L, "CH", "OVERSEAS")).thenReturn("PCHWK"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); service.createDraft(10001L, 2L, request, "validate-channel-on-create"); var ordered = inOrder(pricing, repository); - ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS"); ordered.verify(repository).insertOrder(argThat(order -> "CH".equals(order.getChannelCode()) && "TAX_INCLUDED".equals(order.getPricingTaxMode()))); @@ -133,12 +137,13 @@ class OrderApplicationServiceStateTest { source.amazonWarehouseCode(), source.shipmentId(), source.referenceId()); when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(pricing.requireAvailable(10001L, 8L, " ch ", "OVERSEAS")).thenReturn("PCHWK"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); service.createDraft(10001L, 2L, request, "canonical-channel-code"); - verify(pricing).requireAvailable(10001L, 8L, " ch "); + verify(pricing).requireAvailable(10001L, 8L, " ch ", "OVERSEAS"); verify(repository).insertOrder(argThat(order -> "CH".equals(order.getChannelCode()) && "BUY_DECLARATION".equals(order.getTaxMode()) @@ -182,7 +187,7 @@ class OrderApplicationServiceStateTest { service.updateDraft(10001L, 2L, 1L, request); var ordered = inOrder(pricing, repository); - ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS"); ordered.verify(repository).saveOrder(10001L, order); } @@ -244,11 +249,79 @@ class OrderApplicationServiceStateTest { .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); - assertThat(order.getInboundCode()).isEqualTo("RK-1"); + assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0001"); assertThat(order.getSubmittedAt()).isNotNull(); verify(repository).saveOrder(20002L, order); } + @Test + void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "nyexx2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); + + service.createDraft(10001L, 2L, request, "legacy-code-draft"); + + verify(repository).insertOrder(argThat(order -> "NYEXX2608140001".equals(order.getInboundCode()))); + verify(repository, never()).nextInboundCodeSequence(any()); + } + + @Test + void legacyManualModeRejectsAnInboundCodeAlreadyUsedByAnotherCustomer() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "NYEXX2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.inboundCodeExists(eq("NYEXX2608140001"), anyLong())).thenReturn(true); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); + + assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "duplicate-global-code")) + .isInstanceOf(BizException.class) + .hasMessage("入仓码已存在,请核对旧系统数据"); + + verify(repository, never()).insertOrder(any()); + } + + @Test + void automaticModeRejectsClientSuppliedInboundCodes() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "NYEXX2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + + assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "auto-manual-code")) + .isInstanceOf(BizException.class) + .hasMessage("当前系统由新系统自动生成入仓码,请勿人工填写"); + + verify(repository, never()).insertOrder(any()); + } + + @Test + void legacyManualModeRejectsSubmissionWithoutAnExternalInboundCode() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); + + assertThatThrownBy(() -> service.transit( + 20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null)) + .isInstanceOf(BizException.class) + .hasMessage("当前系统使用旧系统入仓码,请先填写入仓码"); + + verify(repository, never()).nextInboundCodeSequence(any()); + verify(repository, never()).saveOrder(anyLong(), any()); + } + @Test void submittingLegacyDraftRequiresPricingTaxMode() { OrderRepository repository = mock(OrderRepository.class); @@ -274,7 +347,7 @@ class OrderApplicationServiceStateTest { when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); when(repository.findParties(20002L, 1L)).thenReturn(List.of()); doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择")) - .when(pricing).requireAvailable(20002L, 8L, "CH"); + .when(pricing).requireAvailable(20002L, 8L, "CH", "OVERSEAS"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); @@ -354,14 +427,14 @@ class OrderApplicationServiceStateTest { ReflectionTestUtils.setField(service,"repository",repository); ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility); ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class)); - ReflectionTestUtils.setField(service,"json",new ObjectMapper()); + ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null)) .isInstanceOf(BizException.class).hasMessageContaining("重复提交"); verify(repository,never()).saveOrder(anyLong(), any()); } @Test - void publicDestinationCanBeSavedWithoutPartnerSenderOrReceiver() { + void publicDestinationUsesAuthenticatedEnterpriseAsCustomerWithoutPartySnapshots() { OrderRepository repository = mock(OrderRepository.class); OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class); OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); @@ -372,7 +445,7 @@ class OrderApplicationServiceStateTest { service(repository, eligibility, masterData) .createDraft(20002L, 2L, request, "public-destination-no-parties"); - verify(repository).insertOrder(argThat(order -> order.getCustomerId() == null + verify(repository).insertOrder(argThat(order -> Long.valueOf(20002L).equals(order.getCustomerId()) && "当前企业".equals(order.getCustomerName()))); verify(repository, never()).insertParty(any()); } @@ -486,18 +559,41 @@ class OrderApplicationServiceStateTest { verify(repository, never()).saveOrder(anyLong(), any()); } + @Test + void inboundCodeUsesChannelPrefixShanghaiDateAndDailyTicketSequence() { + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 1L)) + .isEqualTo("PCHK2608140001"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 2L)) + .isEqualTo("PCHK2608140002"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 15), 1L)) + .isEqualTo("PCHK2608150001"); + assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 14), 10000L)) + .isEqualTo("HDHK26081410000"); + } + private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, OrderMasterDataGuard masterData) { OrderApplicationService service = springService; ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility); ReflectionTestUtils.setField(service, "masterDataGuard", masterData); - ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class)); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK"); + when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); - ReflectionTestUtils.setField(service, "json", new ObjectMapper()); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.AUTO); + ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules()); return service; } + private void useInboundCodeMode(OrderApplicationService service, InboundCodeModeProvider.Mode mode) { + InboundCodeModeProvider provider = mock(InboundCodeModeProvider.class); + when(provider.mode()).thenReturn(mode); + when(provider.legacyManual()).thenReturn(mode == InboundCodeModeProvider.Mode.LEGACY_MANUAL); + ReflectionTestUtils.setField(service, "inboundCodeModeProvider", provider); + } + private OrderRequests.SaveRequest request() { var party = new OrderRequests.Party( "公司", "联系人", "13800138000", null, "CN", null, "深圳", null, "地址", null); @@ -508,10 +604,23 @@ class OrderApplicationServiceStateTest { null, null, null, null, null, null); return new OrderRequests.SaveRequest( 7L, "客户", "SEA", null, "US", null, "目的仓", null, null, - party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司", + party, party, List.of(cargo), "PICKUP", LocalDateTime.of(2026, 8, 20, 9, 0), + 8L, "收货仓", "CH", "渠道", 9L, "船司", "BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null); } + private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) { + return new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), inboundCode); + } + private OrderRequests.SaveRequest request(Long customerId, String pickupMethod, OrderRequests.Party sender, OrderRequests.Party receiver, String destinationType) { @@ -522,7 +631,9 @@ class OrderApplicationServiceStateTest { null, null, null, null, null, null); return new OrderRequests.SaveRequest( customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null, - sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司", + sender, receiver, List.of(cargo), pickupMethod, + "PICKUP".equals(pickupMethod) ? LocalDateTime.of(2026, 8, 20, 9, 0) : null, + 8L, "收货仓", "CH", "渠道", 9L, "船司", "BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null); } @@ -540,15 +651,15 @@ class OrderApplicationServiceStateTest { } private OrderMasterDataGuard.Snapshots snapshots() { - return snapshots(new PartnerClient.Partner(7L, 0L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER"))); + return snapshots(null); } - private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) { + private OrderMasterDataGuard.Snapshots snapshots(Object ignored) { var warehouse = new WarehouseClient.Warehouse(8L, 0L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE"); var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null); var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0)); var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability)); - return new OrderMasterDataGuard.Snapshots(partner, receiving, null); + return new OrderMasterDataGuard.Snapshots(receiving, null); } private OrderEntity persistedDraft(String pickupMethod, String destinationType) { diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java index 1597c37c4..7636a6b38 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java @@ -53,6 +53,11 @@ class OrderFulfillmentSplitServiceTest { assertThat(response.loadedChild().sourceOrderId()).isEqualTo(100L); assertThat(response.loadedChild().loadingPlanId()).isEqualTo(701L); assertThat(response.remainderChild().containerBookingId()).isEqualTo(801L); + ArgumentCaptor childOrder = ArgumentCaptor.forClass(OrderEntity.class); + verify(orders).insert(childOrder.capture()); + assertThat(childOrder.getValue().getInboundCode()) + .isEqualTo("ZBYHK2608170001-1") + .isNotEqualTo(parent.getInboundCode()); ArgumentCaptor rows = ArgumentCaptor.forClass(OrderFulfillmentSplitEntity.class); verify(splits, times(2)).insert(rows.capture()); assertThat(rows.getAllValues()).allSatisfy(row -> { @@ -174,6 +179,7 @@ class OrderFulfillmentSplitServiceTest { OrderEntity child = invocation.getArgument(0); assertThat(child.getOrderNo()).isEqualTo("ORDER-001-2"); assertThat(child.getParentOrderId()).isEqualTo(100L); + assertThat(child.getInboundCode()).isEqualTo("ZBYHK2608170001-2"); return 1; }); when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1); @@ -209,6 +215,7 @@ class OrderFulfillmentSplitServiceTest { private static OrderEntity parent() { OrderEntity parent = new OrderEntity(); parent.setId(100L); parent.setTenantId(10L); parent.setOrderNo("ORDER-001"); + parent.setInboundCode("ZBYHK2608170001"); parent.setOrderRole("MASTER"); parent.setVersion(3); parent.setStatus(OrderStatus.PROCESSING); return parent; } diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index f56372c80..6efebce49 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.entity.OrderIdempotencyRecordEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,6 +42,7 @@ class OrderIdempotencyServiceTest { @MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @MockBean private ProductImageGuard contextProductImageGuard; + @MockBean private InboundCodeModeProvider contextInboundCodeModeProvider; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 6b3fc6e73..9b3fb236a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -3,241 +3,92 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.dto.OrderRequests; -import feign.FeignException; -import feign.Request; -import feign.Response; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; 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 java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; -@DisplayName("订单主数据校验") +import java.math.BigDecimal; +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.verify; +import static org.mockito.Mockito.when; + @SpringJUnitConfig(OrderMasterDataGuard.class) @TestPropertySource(properties = "baiyue.security.internal-service-token=token") class OrderMasterDataGuardTest { - @MockBean private WarehouseClient warehouses; - @MockBean private PartnerClient partners; - @Resource private OrderMasterDataGuard guard; private static final long PUBLIC_TENANT_ID = 0L; - @Test - void validMasterDataReturnsTrustedServerSnapshot() { - var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); - var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null); - var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0)); - var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability)); - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner)); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh)); - var result=guard.validate(10001,request(7L,8L)); - assertThat(result.partner().partnerName()).isEqualTo("服务端客户名"); - assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); - } - @Test - void inactiveOrCrossTenantMasterDataIsRejected() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null)); - assertThatThrownBy(() -> guard.validate(10002,request(7L,8L))) - .isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用"); - } - @Test - void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn( - ApiResponse.failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null)); + @MockBean private WarehouseClient warehouses; + @Resource private OrderMasterDataGuard guard; - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) + @Test + void validatesWarehousesWithoutTreatingCustomerAsWarehousePartner() { + when(warehouses.active("token", PUBLIC_TENANT_ID, 8L)) + .thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true))); + + var result = guard.validate(20002L, request(8L, "PRIVATE", null)); + + assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("仓库8"); + verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L); + } + + @Test + void persistedCustomerMustMatchAuthenticatedEnterprise() { + assertThatThrownBy(() -> guard.validatePersisted(20002L, 30003L, 8L, null, "PRIVATE")) .isInstanceOf(BizException.class) - .hasMessage("往来单位不存在") + .hasMessage("订单客户必须是当前登录企业") .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.NOT_FOUND); - verifyNoInteractions(warehouses); + .isEqualTo(ResultCode.FORBIDDEN); } - @Test - void masterDataResponseWithNullElementReturnsControlledBusinessError() { - var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); - var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"), - Collections.singletonList(null), Collections.singletonList(null)); - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner)); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh)); - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.VALIDATION_FAILED); + @Test + void customerTenantCanUsePublicDomesticReceivingWarehouse() { + when(warehouses.active("token", PUBLIC_TENANT_ID, 8L)) + .thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true))); + + guard.validate(20002L, request(8L, "PRIVATE", null)); + + verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L); } + @Test - void masterDataResponseMissingSnapshotNameIsServiceUnavailable() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,null,"ACTIVE","SHIPPER",List.of("SHIPPER")))); + void destinationWarehouseTypeMustMatchDestinationType() { + when(warehouses.active("token", PUBLIC_TENANT_ID, 8L)) + .thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true))); + when(warehouses.active("token", PUBLIC_TENANT_ID, 9L)) + .thenReturn(ApiResponse.success(warehouse(9L, "OVERSEAS", false))); - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); - verifyNoInteractions(warehouses); - } - @Test - void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("解码失败")); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); - } - @Test - void downstreamForbiddenMapsToServiceUnavailable() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(403)); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); - } - @Test - void downstreamHttpNotFoundReturnsPlatformPartnerUnavailableMessage() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(404)); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .hasMessage("平台往来单位不存在或不可用") - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.NOT_FOUND); - } - @Test - void wrappedDownstreamHttpConflictMapsToServiceUnavailable() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("wrapped", feign(409))); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); - } - @Test - void downstreamServerErrorMapsToServiceUnavailable() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(500)); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); - } - @Test - void inactiveMasterDataInSuccessfulResponseMapsToConflict() { - when(partners.active("token",PUBLIC_TENANT_ID,7)) - .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","INACTIVE","SHIPPER",List.of("SHIPPER")))); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - } - @Test - void partnerWithoutShipperTypeIsRejected() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE")))); - - assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) - .isInstanceOf(BizException.class) - .extracting(exception -> ((BizException) exception).getResultCode()) - .isEqualTo(ResultCode.VALIDATION_FAILED); - verifyNoInteractions(warehouses); - } - @Test - void customerTenantCanUseDomesticWarehouseFromPublicTenant() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( - warehouse(8L,"DOMESTIC",true))); - - guard.validate(20002,request(7L,8L)); - - verify(warehouses).active("token",PUBLIC_TENANT_ID,8); - } - @Test - void customersAAndBUseTheSamePlatformWarehouseAndPartner() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"平台共享单位","ACTIVE","SHIPPER",List.of("SHIPPER")))); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( - warehouse(8L,"DOMESTIC",true))); - - guard.validate(20002,request(7L,8L)); - guard.validate(30003,request(7L,8L)); - - verify(partners,times(2)).active("token",PUBLIC_TENANT_ID,7); - verify(warehouses,times(2)).active("token",PUBLIC_TENANT_ID,8); - } - @Test - void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() { - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( - warehouse(8L,"DOMESTIC",true))); - - var result=guard - .validate(20002,request(null,8L)); - - assertThat(result.partner()).isNull(); - verifyNoInteractions(partners); - verify(warehouses).active("token",PUBLIC_TENANT_ID,8); - } - @Test - void fbaDestinationMustReferenceMatchingPublicWarehouse() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( - warehouse(8L,"DOMESTIC",true))); - when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success( - warehouse(9L,"OVERSEAS",false))); - - assertThatThrownBy(() -> guard - .validate(20002,request(7L,8L,"FBA",9L))) + assertThatThrownBy(() -> guard.validate(20002L, request(8L, "FBA", 9L))) .isInstanceOf(BizException.class) .hasMessageContaining("目的仓库类型必须为 FBA"); } - @Test - void privateDestinationCannotReferencePublicWarehouse() { - when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); - when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( - warehouse(8L,"DOMESTIC",true))); - assertThatThrownBy(() -> guard - .validate(20002,request(7L,8L,"PRIVATE",9L))) - .isInstanceOf(BizException.class) - .hasMessageContaining("私人地址不能引用公共仓库"); - verify(warehouses,never()).active("token",PUBLIC_TENANT_ID,9); + private OrderRequests.SaveRequest request(long receivingWarehouseId, String destinationType, + Long destinationWarehouseId) { + var party = new OrderRequests.Party("公司", "联系人", "13800138000", null, + "CN", null, "深圳", null, "地址", null); + var cargo = new OrderRequests.Cargo("货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, + null, null, null, null, null, false, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null); + return new OrderRequests.SaveRequest(null, "当前企业", "SEA", null, "CN", destinationWarehouseId, + "目的仓", null, null, party, party, List.of(cargo), "PICKUP", receivingWarehouseId, + "收货仓", "CH", "渠道", null, null, "BUY_DECLARATION", null, "LCL", false, + destinationType, null, null, null); } - private FeignException feign(int status) { - Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test", - Map.of(), null, StandardCharsets.UTF_8, null); - Response response = Response.builder().status(status).reason("test").request(request) - .headers(Map.of()).build(); - return FeignException.errorStatus("masterData", response); - } - private OrderRequests.SaveRequest request(Long partner,Long warehouse) { - return request(partner,warehouse,"PRIVATE",null); - } - private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) { - var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); - var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, - null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null, - null,null,null,null,null,null); - return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",null,null,"BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null); - } - private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) { - var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE"); - var address=new WarehouseClient.Address("CN","广东省","深圳市",null,null,"示例地址",null); - var capabilities=receiving - ? List.of(new WarehouseClient.Capability("RECEIVING",true,null)) + + private WarehouseClient.Detail warehouse(long id, String type, boolean receiving) { + var value = new WarehouseClient.Warehouse(id, PUBLIC_TENANT_ID, "WH-" + id, "仓库" + id, + type, "CN", "ACTIVE"); + var address = new WarehouseClient.Address("CN", "广东省", "深圳市", null, null, "示例地址", null); + var capabilities = receiving + ? List.of(new WarehouseClient.Capability("RECEIVING", true, null)) : List.of(); - return new WarehouseClient.Detail(value,List.of(address),capabilities); + return new WarehouseClient.Detail(value, List.of(address), capabilities); } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java index a68617c16..d26d9b3e6 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,21 +31,34 @@ class OrderPricingChannelGuardTest { @Test void forwardsCustomerOriginAndChannelToFirstleg() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); - when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null)); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success( + new FirstlegPricingClient.ChannelValidationResponse("MATSON_TRUCK", "ZBYHK"))); - guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"); + assertThat(guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")).isEqualTo("ZBYHK"); + + verify(client).validateChannel("token", request); + } + + @Test + void legacyManualModeOnlyRequiresTheChannelToRemainActive() { + var request = new FirstlegPricingClient.ChannelValidationRequest( + 20002L, 8L, "LEGACY_CHANNEL", "FBA", false); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success( + new FirstlegPricingClient.ChannelValidationResponse("LEGACY_CHANNEL", null))); + + guard.requireActive(20002L, 8L, "LEGACY_CHANNEL", "FBA"); verify(client).validateChannel("token", request); } @Test void removedOrForgedChannelKeepsFirstlegBusinessError() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE", "FBA", true); when(client.validateChannel("token", request)).thenReturn( ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); - assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE")) + assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE", "FBA")) .isInstanceOf(BizException.class) .hasMessage("所选渠道已下线,请重新选择") .extracting(exception -> ((BizException) exception).getResultCode()) @@ -53,18 +67,18 @@ class OrderPricingChannelGuardTest { @Test void nullResponseMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); when(client.validateChannel("token", request)).thenReturn(null); - assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); } @Test void downstreamHttpFailureMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); when(client.validateChannel("token", request)).thenThrow(feign(403)); - assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); } private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index 10e802c31..49d6131eb 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -20,6 +21,7 @@ class PlatformOrderPageServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index e8684f703..6ce84eb99 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.order.config.InboundCodeModeProvider; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -23,6 +24,7 @@ class TenantOrderCountServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java index 31779bf61..a9186caca 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.dto.TenantOrderStatisticsResponse; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -27,6 +28,7 @@ class TenantOrderStatisticsServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 3f7077e08..aa5c9a3d6 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; @@ -19,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDate; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; import java.util.stream.IntStream; @@ -27,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -73,12 +77,77 @@ class UnifiedOrderQueryServiceTest { verify(firstleg, never()).summaries(any(), any()); } + @Test + void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(77L); + cargo.setPackageCount(100); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of())); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo)); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + verify(orders).findCargoByOrderIds(10001L, List.of(77L)); + } + + @Test + void inboundPackageCountRemainsTheFulfillmentFactAfterSplit() { + UnifiedOrderQuery query = query(null); + OrderEntity child = order(OrderStatus.APPROVED); + child.setParentOrderId(66L); + OrderCargoEntity inheritedOriginal = new OrderCargoEntity(); + inheritedOriginal.setOrderId(77L); + inheritedOriginal.setPackageCount(100); + var summary = summary("LOADING", null); + when(summary.inbound().totalPackageCount()).thenReturn(35); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(inheritedOriginal)); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(35); + } + + @Test + void inboundMasterUsesTotalPackageCountEvenWhenLegacyPackageCountIsAbsent() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + var summary = summary("WAIT_MEASURE", null); + when(summary.inbound().totalPackageCount()).thenReturn(100); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + } + + @Test + void missingCargoAndInboundReturnsNullInsteadOfFabricatedZero() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of())); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of()); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isNull(); + } + @Test void pendingReviewInboundCodeFilterStaysInOrderQuery() { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, "PENDING_REVIEW", null, null, null, "RK-77", null, - null, null, null, null, null, null, null, null, null, null, - null, null, 1, 20); + null, null, null, null, null, null, null, null, null, + null, null, null, 1, 20); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -167,12 +236,32 @@ class UnifiedOrderQueryServiceTest { assertThat(row.loading().loadingVersion()).isEqualTo(3); assertThat(row.loading().pickingVersion()).isEqualTo(3); assertThat(row.inboundCode()).isEqualTo("RK-77"); - assertThat(row.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(row.recommendedPalletType()).isNull(); assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03"); + assertThat(row.companyName()).isEqualTo("测试客户有限公司"); + assertThat(row.businessType()).isEqualTo("SEA_LCL"); + assertThat(row.totalPalletCount()).isEqualTo(2); + assertThat(row.chargeableWeightKg()).isEqualByComparingTo("125.500"); + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("1747.500"); }); verify(orders, never()).unifiedPage(any(), any(), any()); } + @Test + void unifiedRowDoesNotUseGrossWeightAsNetWeightFallback() { + UnifiedOrderQuery query = query("INBOUNDED"); + var summary = summary("INBOUNDED", null); + when(summary.inbound().actualNetWeightKg()).thenReturn(null); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + var row = service.page(null, query).records().get(0); + + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(row.totalNetWeightKg()).isNull(); + } + @Test void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { UnifiedOrderQuery query = query("WAIT_SPLIT"); @@ -192,6 +281,61 @@ class UnifiedOrderQueryServiceTest { assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); } + @Test + void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + var summary = summary("LOADED", null); + when(summary.inbound().estimatedSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 20, 8, 0)); + when(summary.inbound().actualSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 21, 9, 0)); + when(summary.inbound().estimatedPortArrivalAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 8, 0)); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("LOADED"); + assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(row.inboundId()).isEqualTo(88L); + assertThat(row.transport().effectiveSailingAt()).isEqualTo(LocalDateTime.of(2026, 8, 21, 9, 0)); + assertThat(row.transport().effectiveSailingAtEstimated()).isFalse(); + assertThat(row.transport().effectivePortArrivalAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 8, 0)); + assertThat(row.transport().effectivePortArrivalAtEstimated()).isTrue(); + }); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class); + verify(firstleg).summaries(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().tenantId()).isEqualTo(10001L); + assertThat(captor.getValue().orderIds()).containsExactly(77L); + } + + @Test + void detailKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of()); + when(firstleg.detail(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(fulfillmentDetail)); + + var result = service.detail(10001L, 77L, false); + + assertThat(result.summary().mainStatus()).isEqualTo("LOADED"); + assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(result.summary().inboundId()).isEqualTo(88L); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class); + verify(firstleg).detail(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().tenantId()).isEqualTo(10001L); + assertThat(captor.getValue().orderId()).isEqualTo(77L); + } + @Test void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( @@ -275,11 +419,33 @@ class UnifiedOrderQueryServiceTest { } private FirstlegUnifiedOrderClient.Summary summary(String status, String action) { - var inbound = new FirstlegUnifiedOrderClient.Inbound( - 88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7, - 30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null, - null, null, null, null, null, null, null, null, null, - LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0)); + var inbound = mock(FirstlegUnifiedOrderClient.Inbound.class); + when(inbound.id()).thenReturn(88L); + when(inbound.tenantId()).thenReturn(10001L); + when(inbound.orderId()).thenReturn(77L); + when(inbound.orderNo()).thenReturn("FL-77"); + when(inbound.inboundCode()).thenReturn("RK-77"); + when(inbound.processStatus()).thenReturn("INBOUNDED"); + when(inbound.orderStatusSnapshot()).thenReturn("APPROVED"); + when(inbound.measureReviewStatus()).thenReturn("APPROVED"); + when(inbound.version()).thenReturn(7); + when(inbound.customerId()).thenReturn(30001L); + when(inbound.customerName()).thenReturn("测试客户"); + when(inbound.warehouseId()).thenReturn(11L); + when(inbound.warehouseName()).thenReturn("深圳仓"); + when(inbound.deliveryWarehouseId()).thenReturn(22L); + when(inbound.deliveryWarehouseName()).thenReturn("ONT8"); + when(inbound.channelName()).thenReturn("美森"); + when(inbound.totalPackageCount()).thenReturn(10); + when(inbound.locationCodes()).thenReturn("A0-01, B2-03"); + when(inbound.companyName()).thenReturn("测试客户有限公司"); + when(inbound.businessType()).thenReturn("SEA_LCL"); + when(inbound.palletCount()).thenReturn(2); + when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500")); + when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500")); + when(inbound.chargeableWeightKg()).thenReturn(new BigDecimal("125.500")); + when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0)); + when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0)); return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null), new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null); diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 052a3daa0..76ff7aa0a 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -16,5 +16,5 @@ ## 修改与验证 - 新增条目前先确认制品确实需要被其他仓库按固定版本消费;业务服务依赖不要放入 BOM。 -- 版本变化会影响所有 BOM 消费者,并可能需要尾程仓同步升级;保持已发布坐标兼容。 +- 版本变化会影响所有 BOM 消费者;当前开发阶段直接同步本仓与尾程仓消费者,不保留旧坐标或旧版本兼容。 - 从父目录运行 `mvn -pl platform-bom -am validate`。 diff --git a/scripts/README.md b/scripts/README.md index 16c309270..8b4e5ba46 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,13 +5,22 @@ | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | -| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 | -| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 | +| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | +| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | +| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。 +## 固定客户待审核订单 + +需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。可直接在文件顶部 `CONFIG` 中填写 `GATEWAY`、`USERNAME`、`PASSWORD` 和 `COUNT`;固定账号为 `17512095155`。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 + +```powershell +node scripts/create-pending-review-orders.mjs +``` + ## 本地质量检查(非全量验收) 以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用: @@ -23,6 +32,14 @@ | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | +Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: + +| 合同范围 | 入口 | +|---|---| +| Admin 订单、箱码、三期装柜/PDA、订舱、拖车、通知 | `scripts/admin-test-env-modules/*contract.test.mjs`;模块映射见 `scripts/admin-test-env-modules/README.md` | +| Client 消息中心调用 | `scripts/client-notifications-contract.test.mjs` | +| 验收入口与模块注册 | `scripts/full-regression-entry-contract.test.mjs`、`scripts/controller-pipeline-contract.test.mjs` | + ## 后端全量回归 ## 入口 @@ -87,7 +104,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令 ### 本地 MySQL 全量验收 -正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899` 和 14812 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 +正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA` 和 15403 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 ```powershell .\scripts\local-db-full-acceptance.ps1 -DryRun diff --git a/scripts/acceptance-finance-full-contract.test.mjs b/scripts/acceptance-finance-full-contract.test.mjs index c89ee67d8..3f77ae4ac 100644 --- a/scripts/acceptance-finance-full-contract.test.mjs +++ b/scripts/acceptance-finance-full-contract.test.mjs @@ -9,11 +9,12 @@ import { AcceptanceRun } from './admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'admin-test-env-acceptance.mjs'), 'utf8') -test('默认全量按 fixture、reconciliation、settlement 顺序接入且不重复旧 finance', () => { - const match = source.match(/const DEFAULT_MODULES = \[(.*?)\]/s) +test('默认全量按 fixture、reconciliation、settlement、downloads 顺序接入且不重复旧 finance', () => { + const match = source.match(/const CONFIGURED_MODULES = \[(.*?)\]/s) assert.ok(match) const modules = [...match[1].matchAll(/'([^']+)'/g)].map((item) => item[1]) - assert.deepEqual(modules.slice(-3), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']) + const financeStart = modules.indexOf('finance-full-fixtures') + assert.deepEqual(modules.slice(financeStart, financeStart + 4), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads']) assert.equal(modules.filter((item) => item === 'finance').length, 1) assert.ok(modules.indexOf('finance') < modules.indexOf('finance-full-fixtures')) assert.match(source, /fullFinanceChain/) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 40a54a2ef..84b3f5819 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,10 +12,91 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] + const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking'] + const firstManualGroupIndex = normalized.findIndex((name) => manualGroups.includes(name)) + if (!normalized.includes('manual-state-matrix') && firstManualGroupIndex >= 0) { + normalized.splice(firstManualGroupIndex, 0, 'manual-state-matrix') + } + const manualMatrixIndex = normalized.indexOf('manual-state-matrix') + if (manualMatrixIndex >= 0) { + for (const group of manualGroups) { + const internalGroupIndex = normalized.indexOf(group) + if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1) + } + const prerequisites = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'ocean-bookings', + 'phase3-customs', + 'phase3-loading-finalize', + ] + for (const prerequisite of prerequisites) { + if (!normalized.includes(prerequisite)) { + normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite) + } + } + } + if (normalized.includes('phase3-order-splitting')) { + const prerequisites = ['merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading'] + for (const prerequisite of prerequisites) { + if (!normalized.includes(prerequisite)) { + normalized.splice(normalized.indexOf('phase3-order-splitting'), 0, prerequisite) + } + } + const splitIndex = normalized.indexOf('phase3-order-splitting') + const loadingIndex = normalized.indexOf('phase3-loading') + if (splitIndex < loadingIndex) { + normalized.splice(splitIndex, 1) + normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-order-splitting') + } + } + const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] + const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) + if (firstFinanceIndex >= 0) { + const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading'] + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(firstFinanceIndex, 0, ...missing) + } + const financeDownloadsIndex = normalized.indexOf('finance-downloads') + if (financeDownloadsIndex >= 0 && financeChain.some((name) => normalized.includes(name))) { + for (const prerequisite of financeChain) { + if (!normalized.includes(prerequisite)) normalized.splice(normalized.indexOf('finance-downloads'), 0, prerequisite) + } + const completeFinanceChain = [...financeChain, 'finance-downloads'] + const firstChainIndex = normalized.findIndex((name) => completeFinanceChain.includes(name)) + let predecessor = firstChainIndex > 0 ? normalized[firstChainIndex - 1] : null + if (!predecessor) predecessor = ['phase3-container-loading', 'inbound-measurement', 'orders', 'merchant-qualification-fixture'] + .find((name) => normalized.includes(name)) || null + const withoutChain = normalized.filter((name) => !completeFinanceChain.includes(name)) + const insertionIndex = predecessor ? withoutChain.indexOf(predecessor) + 1 : withoutChain.length + withoutChain.splice(insertionIndex, 0, ...completeFinanceChain) + normalized.splice(0, normalized.length, ...withoutChain) + } + if (normalized.includes('fee-items')) { + const prerequisites = ['merchant-qualification-fixture', 'orders'] + const feeItemsIndex = normalized.indexOf('fee-items') + const ordered = prerequisites.every((name, index) => { + const prerequisiteIndex = normalized.indexOf(name) + const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1]) + return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex + }) + if (!ordered) { + const requested = normalized.filter((name) => !prerequisites.includes(name)) + requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites) + normalized.splice(0, normalized.length, ...requested) + } + } + if (normalized.includes('inbound-fees')) { + const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement'] + const targetIndex = normalized.indexOf('inbound-fees') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) + } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized @@ -487,8 +568,8 @@ export class AcceptanceRun { const archiveResponse = await this.request('POST', `${detailPath}/archive`) const archived = this.apiSuccess(module, '归档测试客户', archiveResponse, `tenantId=${tenantId}`, archiveOptions) if (archived) { - this.assert(module, '测试客户归档后处于停用状态', archiveResponse, - archived.status === 'DISABLED', `status=${archived.status ?? 'missing'}`, archiveOptions) + this.assert(module, '测试客户归档后处于 ARCHIVED 状态', archiveResponse, + archived.status === 'ARCHIVED', `status=${archived.status ?? 'missing'}`, archiveOptions) } } @@ -582,7 +663,7 @@ export class AcceptanceRun { const fixtureMarker = `${originalMarker}-LOAD` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', - 'phase3LoadingRequireSharedOverflow'] + 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) @@ -614,9 +695,25 @@ export class AcceptanceRun { orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } + const incrementalMarker = `${originalMarker}-LOAD-INC` + this.marker = incrementalMarker + this.context.phase3LoadingRequireSharedOverflow = false + this.context.phase3LoadingIncrementalPackageCount = 2 + await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) + const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders + const incrementalFixture = incrementalSource ? { + marker: originalMarker, + sourceMarker: incrementalMarker, + tenantId: incrementalSource.tenantId, + orderId: incrementalSource.orderId, + inboundId: incrementalSource.inboundId, + } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), phase3Loading: loadingFixture, + phase3LoadingIncremental: incrementalFixture, inboundMeasurement: loadingFixture, } this.marker = originalMarker diff --git a/scripts/admin-test-env-modules/OWNERSHIP.md b/scripts/admin-test-env-modules/OWNERSHIP.md index 74025e00f..3131b201a 100644 --- a/scripts/admin-test-env-modules/OWNERSHIP.md +++ b/scripts/admin-test-env-modules/OWNERSHIP.md @@ -6,6 +6,7 @@ |---|---|---| | 当前总控对话 | Admin 全量脚本 | 总入口、`system.mjs`、`master-data.mjs`、`downloads.mjs`、最终集成与全量报告 | | `019ff0da-7699-7532-ac64-703325c995fd` | Finance 一期验收总控 | `finance.mjs`、Finance 正式全量接入与报告 | +| `019ff905-a477-7a03-b806-953ba598d305` | Finance 下载中心共享契约 | `finance-downloads.mjs`、直接契约测试与 Finance 完整链接入;不覆盖通用 `downloads.mjs` | | `019fdbd3-f24f-7903-9124-de79c3ba37d0` | 客户尺寸规则 | 总入口内 `customer-dimension-rules` 子场景;不得改其他 customers 流程 | | `019fdb7d-8e29-7c40-bda6-5a29dcb37971` | OCR 扫描 | `inbound-weight-ocr.mjs` | | `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3854e9fa2..0f2c5e531 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -7,6 +7,8 @@ - `system.mjs` - `inbound-weight-ocr.mjs` +`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。 + 模块只需导出: ```js @@ -41,15 +43,43 @@ export async function run(context) { | 业务链路 | HTTP 模块/共享前置 | 离线静态合同 | |---|---|---| | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | +| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | -| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | -| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | -| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | +| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | +| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | +| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | +| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | +| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | +| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | +| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 +只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 + +## WAIT_SPLIT 到 LOADED 冻结链 + +默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为: + +`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱 DRAFT 完整登记 containerRequirements -> RELEASED -> 港口取得真实 containerNo -> 按 oceanBookingId + requirementId + containerNo + expectedVersion 原地关联并创建 allocation -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。 + +其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。 + +计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 + +`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 + +部署后需要定向验证该完整链时,模块顺序固定为: + +```powershell +node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize +``` + +该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。 + ## 消息中心模块 `notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权: @@ -78,6 +108,10 @@ node scripts/admin-test-env-acceptance.mjs --modules suppliers node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs ``` +## Finance 全链前置 + +`finance-full-fixtures` 会通过正式供应商 API 创建本轮隔离的 ACTIVE 供应商,并按费用项目类别校验后再生成 PAYABLE;不会使用历史供应商 ID。只请求 `finance-full-fixtures,finance-reconciliation-full,finance-settlement-full,finance-downloads` 时,总入口会自动补齐 `merchant-qualification-fixture -> orders -> inbound-measurement -> phase3-container-loading`,确保 Finance 能消费本轮真实已完成柜次,并在结算链完成后验证八类 XLSX 下载任务。若这些前置仍未产出同 marker 的完成柜次,Finance 会 fail-closed,不能把 finance-only 前置不足计为 Finance 业务成功。单独请求 `finance-downloads` 只执行最小部署能力探测,不借历史 Finance 数据。 + ## 国内整柜与客户运营统计 - `domestic-container.mjs`:整柜入库、出库、库存列表和详情;静态契约为 `domestic-container-contract.test.mjs`。 @@ -88,6 +122,16 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs node --test scripts/admin-test-env-modules/domestic-container-contract.test.mjs ``` +## 货柜履约列表字段合同 + +`fulfillment-list-field-contract.mjs` 统一校验货柜、预装/装柜清单、订舱、船期、拖车、报关协同和国内出仓正式响应中的稳定 ID、非空展示字段、非负数量/重量/体积及 ISO 时间格式。各生产模块在已取得的本轮 marker 响应上执行断言,不额外借用历史数据;未冻结的原型字段只登记在 `docs/三期/货柜履约原型字段验收矩阵-2026-08-17.md` 的 `UNKNOWN`,不得由 fixture 伪造。 + +离线合同: + +```powershell +node --test scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +``` + ## 清库后的商户下单前置 `merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。 diff --git a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs new file mode 100644 index 000000000..e7266b5c6 --- /dev/null +++ b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const supplierFixture = readFileSync(new URL('./suppliers.mjs', import.meta.url), 'utf8') +const financeFixture = readFileSync(new URL('./finance-full-fixtures.mjs', import.meta.url), 'utf8') +const oceanBookingFixture = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8') +const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8') +const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8') +const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8') + +test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geographic scope gate', () => { + assert.match(supplierFixture, /category:'BOOKING'/) + assert.match(supplierFixture, /status:'ACTIVE'/) + assert.doesNotMatch(supplierFixture, /scope\s*:/) + + const supplierBody = financeFixture.slice(financeFixture.indexOf('export function buildSupplierBody'), + financeFixture.indexOf('async function createOrder')) + assert.match(supplierBody, /category,/) + assert.match(supplierBody, /status: 'ACTIVE'/) + assert.doesNotMatch(supplierBody, /scope|DOMESTIC|INTERNATIONAL/) +}) + +test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => { + assert.match(supplierOpenapi, /name: requireActive/) + assert.match(supplierOpenapi, /name: category/) + assert.match(supplierOpenapi, /required: \[supplierId, name, category, status\]/) + assert.doesNotMatch(supplierOpenapi, /\bScope\b|name: scope|\bscope:/) +}) + +test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no domestic/international argument', () => { + assert.match(firstlegSnapshot, /return resolve\(supplierId, suppliedName, "BOOKING"\)/) + assert.match(firstlegSnapshot, /!"ACTIVE"\.equals\(response\.data\(\)\.status\(\)\)/) + assert.match(firstlegSnapshot, /!category\.equals\(response\.data\(\)\.category\(\)\)/) + assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/) + assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s) +}) + +test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => { + assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/) + assert.match(oceanBookingFixture, /bookingAgentId: bookingAgent\.supplierId/) + assert.match(oceanBookingFixture, /订舱持久化启用订舱供应商快照/) + assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/) +}) diff --git a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs new file mode 100644 index 000000000..aba78fffd --- /dev/null +++ b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8') +const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java') +const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +const containerDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java') +const containerService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java') +const fixture = read('phase3-container-loading.mjs') +const fullEntry = read('../admin-test-env-acceptance.mjs') + +test('booking is an editable DRAFT aggregate and RELEASED is immutable', () => { + assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*DRAFT[\s\S]*RELEASED/) + assert.match(oceanOpenApi, /Create an editable DRAFT/) + assert.match(oceanOpenApi, /Replace a DRAFT/) + assert.match(oceanOpenApi, /Delete a DRAFT; RELEASED is immutable/) + assert.doesNotMatch(oceanOpenApi, /CHANGE_PENDING|CANCELLED/) +}) + +test('booking requirements never carry real container numbers or create allocations', () => { + assert.match(oceanOpenApi, /containerRequirements/) + assert.match(oceanOpenApi, /ContainerRequirementRequest/) + assert.match(oceanOpenApi, /quantity/) + assert.match(oceanOpenApi, /no containerNo, container row, or allocation is created/) + assert.doesNotMatch(oceanOpenApi.match(/ContainerRequirementRequest:[\s\S]*?ReleaseRequest:/)?.[0] || '', /containerNo/) +}) + +test('port link mutates the simulation through the frozen four-field request', () => { + assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long oceanBookingId,[\s\S]*Long requirementId,[\s\S]*String containerNo,[\s\S]*Integer expectedVersion/) + assert.match(containerService, /request\.oceanBookingId\(\)/) + assert.match(containerService, /request\.requirementId\(\)/) + assert.match(containerService, /request\.containerNo\(\)/) + assert.match(containerService, /["']RELEASED["']\.equals\(booking\.getStatus\(\)\)/) + assert.doesNotMatch(fixture, /realContainerBookingId/) + assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) +}) + +test('HTTP fixture keeps simulation and plan identities and creates allocation at link', () => { + assert.match(fixture, /关联前后内部柜次、计划和排货明细身份稳定/) + assert.match(fixture, /订舱 allocation 原子创建并指向稳定模拟柜身份/) + assert.match(fixture, /已消费订舱需求拒绝再次关联/) + assert.match(fixture, /RELEASED 订舱进入正式列表/) +}) + +test('default full acceptance keeps the linked simulation before PDA and finalize', () => { + const loading = fullEntry.indexOf("'phase3-container-loading'") + const pda = fullEntry.indexOf("'phase3-pda-convergence'") + const finalize = fullEntry.indexOf("'phase3-loading-finalize'") + assert.ok(loading >= 0 && pda > loading && finalize > pda) +}) diff --git a/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs new file mode 100644 index 000000000..378fc107e --- /dev/null +++ b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const productionFixtures = [ + 'manual-state-loading-pda.mjs', + 'ocean-booking-fixture.mjs', + 'ocean-bookings.mjs', + 'phase3-carrier-schedule-queries.mjs', + 'phase3-container-loading.mjs', + 'phase3-loading.mjs', + 'phase3-order-splitting.mjs', + 'phase3-port-schedules.mjs', +] + +function source(name) { + return readFileSync(new URL(name, import.meta.url), 'utf8') +} + +test('HTTP fixtures use the canonical primary and mismatch container specifications', () => { + const primarySources = productionFixtures.map(source).join('\n') + const containerFlow = source('phase3-container-loading.mjs') + + assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/) + assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/) +}) + +test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => { + for (const name of productionFixtures) { + const fixture = source(name) + assert.doesNotMatch(fixture, /40HQ|45HQ/, name) + assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name) + assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name) + } +}) + +test('the frozen catalog is complete and unambiguous', () => { + // Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg. + const catalog = Object.freeze({ + '20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }), + '40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }), + '40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }), + '40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }), + '45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }), + }) + + assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC']) + assert.equal(new Set(Object.keys(catalog)).size, 5) +}) diff --git a/scripts/admin-test-env-modules/fee-items-contract.test.mjs b/scripts/admin-test-env-modules/fee-items-contract.test.mjs index e34bc070a..87eced03c 100644 --- a/scripts/admin-test-env-modules/fee-items-contract.test.mjs +++ b/scripts/admin-test-env-modules/fee-items-contract.test.mjs @@ -2,12 +2,14 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs' const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') -test('fee detail binding explicitly sends the settlement inclusion contract field', () => { - assert.match(source, /const detailPayload = \{[\s\S]*settlementIncluded: false/) +test('fee detail binding does not send the removed settlement switch', () => { + assert.match(source, /const detailPayload = \{/) + assert.doesNotMatch(source, /settlementIncluded/) }) test('referenced fee-item deletion remains a conflict assertion', () => { @@ -29,5 +31,18 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', () test('baseline checks run only after stale acceptance cleanup', () => { assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)') - < source.indexOf("'分页总数110且默认20条对应6页'")) + < source.indexOf("'正式目录111条且默认20条对应6页'")) +}) + +test('formal baseline includes the system FBA freight item in addition to the 110 controlled items', () => { + assert.match(source, /const BASELINE_COUNT = 111/) + assert.match(source, /const INTERNATIONAL_COUNT = 27/) + assert.match(source, /'FBA_HEAD_FREIGHT'/) +}) + +test('fee-items injects its same-marker merchant and order fixtures for focused execution', () => { + const expected = ['merchant-qualification-fixture', 'orders', 'fee-items'] + assert.deepEqual(normalizeModuleOrder(['fee-items']), expected) + assert.deepEqual(normalizeModuleOrder(['fee-items', 'orders']), expected) + assert.deepEqual(parseArguments(['--modules', 'fee-items']).modules, expected) }) diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index 4f488ebe4..dd3472bdd 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -2,6 +2,9 @@ const MODULE = 'fee-items' const SCENARIO = 'platform-public-fee-items' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const BASE = '/api/platform/v1/fee-items' +const BASELINE_COUNT = 111 +const DOMESTIC_COUNT = 84 +const INTERNATIONAL_COUNT = 27 const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' @@ -42,7 +45,10 @@ function payload(suffix, overrides = {}) { } } function expectedCodes() { - return Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`) + return [ + 'FBA_HEAD_FREIGHT', + ...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`), + ].sort() } export function isAcceptanceFeeItem(row) { return /^ACPT_\d+$/.test(String(row?.feeCode || '')) @@ -84,8 +90,8 @@ export async function run(context) { const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, 'page=1,size=20', OPTIONS) - context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse, - records(initialPage).length === 20 && Number(initialPage?.total) === 110 + context.assert(MODULE, '正式目录111条且默认20条对应6页', initialPageResponse, + records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, @@ -97,13 +103,13 @@ export async function run(context) { const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') const actualCodes = initialRows.map((row) => row.feeCode).sort() - context.assert(MODULE, '初始化110条全部属于平台且状态ACTIVE', initial.response, - initialRows.length === 110 && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), + context.assert(MODULE, '正式目录111条全部属于平台且状态ACTIVE', initial.response, + initialRows.length === BASELINE_COUNT && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), `rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS) - context.assert(MODULE, '国内84条、国际26条范围正确', initial.response, - domestic.length === 84 && international.length === 26, + context.assert(MODULE, '国内84条、国际27条范围正确', initial.response, + domestic.length === DOMESTIC_COUNT && international.length === INTERNATIONAL_COUNT, `DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS) - context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_110', initial.response, + context.assert(MODULE, '正式编码完整覆盖110条受控项目及系统头程运费', initial.response, actualCodes.join(',') === expectedCodes().join(','), `codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS) @@ -115,7 +121,7 @@ export async function run(context) { Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category), `records=${records(categoryPage).length}`, OPTIONS) - for (const [scope, expected] of [['DOMESTIC', 84], ['INTERNATIONAL', 26]]) { + for (const [scope, expected] of [['DOMESTIC', DOMESTIC_COUNT], ['INTERNATIONAL', INTERNATIONAL_COUNT]]) { const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, @@ -135,8 +141,8 @@ export async function run(context) { const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) - context.assert(MODULE, 'ACTIVE 筛选返回110条且无停用记录', activeResponse, - Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'), + context.assert(MODULE, 'ACTIVE 筛选返回111条且无停用记录', activeResponse, + Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { @@ -209,7 +215,7 @@ export async function run(context) { const feeDetailPath = `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details?accountId=${encodeURIComponent(inbound.tenantId)}` const detailPayload = { feeItemId, direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), - unitPrice: 1.25, currency: 'CNY', amount: 1.25, settlementIncluded: false, + unitPrice: 1.25, currency: 'CNY', amount: 1.25, remark: context.marker, } const disabledBindingResponse = await context.request('POST', feeDetailPath, detailPayload) @@ -266,8 +272,8 @@ export async function run(context) { `feeItemId=${feeItemId}`, OPTIONS) } const final = await listAll(context) - context.assert(MODULE, '清理后恢复110条基线且无临时编码或名称', final.response, - final.rows.length === 110 && !final.rows.some((row) => row.feeCode === createPayload.feeCode + context.assert(MODULE, '清理后恢复111条正式基线且无临时编码或名称', final.response, + final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode || String(row.feeTypeName).includes(suffix)), `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) context.createdResources.feeItems = { diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs index f09ad4e4b..6482476d2 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs @@ -22,7 +22,7 @@ test('覆盖费用、对账单、AR 和 AP 页面分层状态', () => { for (const status of ['DRAFT', 'PENDING_REVIEW', 'ISSUED']) assert.match(source, new RegExp(`'${status}'`)) assert.match(source, /reviewStatus === 'PENDING'/) assert.match(source, /reviewStatus === 'APPROVED'/) - assert.match(source, /settlementIncluded === true/) + assert.doesNotMatch(source, /settlementIncluded|settlement-inclusion/) }) test('平台列表默认全量查询不带 customerId,未部署时登记 blocker', () => { @@ -36,7 +36,7 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => assert.match(source, /fixture\.voucherFileId/) assert.match(source, /finance-full-fixtures\.mjs/) assert.match(source, /context\.createdResources\.financeDemo = matrix/) - assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/) + assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-submit`/) assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/) }) @@ -49,8 +49,8 @@ test('费用来源使用 operationVersion,应付供应商长整型保持字符 assert.match(source, /expectedVersion: operationVersion\(current\.value\)/) assert.match(source, /supplierId: id\(source\.supplierId\)/) assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/) - assert.match(source, /partnerId, partnerName:/) - assert.doesNotMatch(source, /partnerId: Number\(partnerId\)/) + assert.match(source, /supplierId, remark:/) + assert.doesNotMatch(source, /partnerId|partnerName/) }) test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => { diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index 69bf7a84d..4ce0c5dc7 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -52,22 +52,21 @@ async function sourceDetail(context, feeSourceId) { return { response, value: data(response) } } -async function approveAndInclude(context, source, role) { +async function approveSource(context, source, role) { let current = await sourceDetail(context, source.feeSourceId) + if (current.value?.reviewStatus === 'DRAFT' || current.value?.reviewStatus === 'REJECTED') { + const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/submit`, + { reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-submit`) + current = { response, value: data(response) } + } if (current.value?.reviewStatus === 'PENDING') { const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/review`, { action: 'APPROVE', reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-approve`) current = { response, value: data(response) } } - if (current.value?.settlementIncluded !== true) { - const response = await write(context, 'PUT', `${BASE}/fee-sources/${source.feeSourceId}/settlement-inclusion`, - { included: true, reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-include`) - current = { response, value: data(response) } - } - record(context, `${role} 来源已审核并纳入结算`, current.response, - current.value?.reviewStatus === 'APPROVED' && current.value?.settlementIncluded === true - && current.value?.sourceStatus === 'AVAILABLE', - `feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}, included=${current.value?.settlementIncluded}`) + record(context, `${role} 来源已审核并自动进入结算候选`, current.response, + current.value?.reviewStatus === 'APPROVED' && current.value?.sourceStatus === 'AVAILABLE', + `feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}`) return current.value } @@ -75,20 +74,20 @@ async function createExtraPayable(context, fixture, index) { const itemResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const feeItem = rows(data(itemResponse)).find((item) => item.status === 'ACTIVE' && item.warehousePayable === true) const inboundId = id(fixture.orders.at(-1)?.inboundId) - const partnerId = id(fixture.supplier?.partnerId ?? fixture.supplier?.supplierId) - if (!feeItem || !inboundId || !partnerId) return null + const supplierId = id(fixture.supplier?.supplierId) + if (!feeItem || !inboundId || !supplierId) return null const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), - unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, - partnerId, partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` } + unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', + supplierId, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) const created = data(createdResponse) if (!ok(createdResponse) || !created?.id) return null const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`) record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), - `sourceFeeId=${created.id}, inboundId=${inboundId}, partnerId=${partnerId}`) + `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`) return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, - supplierId: partnerId, supplierName: body.partnerName } + supplierId, supplierName: fixture.supplier?.supplierName || null } } async function createStatement(context, source, role, targetStatus) { @@ -206,7 +205,7 @@ async function recoverExistingDemo(context, reportPath) { 'invalid-finance-demo-recovery-source') continue } - const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role) + const approved = await approveSource(context, { feeSourceId: payableIds[index] }, role) const existing = await recoverPayable(context, matrix.payableSettlements[role], payableIds[index]) const recoverySupplierId = role === 'apPartial' ? id(matrix.payableSettlements?.apPaid?.supplierId) @@ -234,7 +233,7 @@ async function recoverExistingDemo(context, reportPath) { matrix.receivableSettlements[role] = value verifySettlementState(context, 'RECEIVABLE', role, response, value, expectedStatus) } - matrix.feeSources.approvedUnbilled = await approveAndInclude(context, { feeSourceId: '300' }, 'approved-unbilled') + matrix.feeSources.approvedUnbilled = await approveSource(context, { feeSourceId: '300' }, 'approved-unbilled') context.createdResources.financeFullFixtures = fixture context.createdResources.financeDemo = matrix } @@ -294,7 +293,7 @@ export async function run(context) { const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const sources = produced.map((source) => ({ ...source, feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, - supplierId: id(source.supplierId ?? source.partnerId ?? fixture.supplier?.supplierId) })) + supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) })) .filter((source) => source.feeSourceId) const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') const payables = sources.filter((source) => source.direction === 'PAYABLE' && source.supplierId) @@ -311,10 +310,10 @@ export async function run(context) { const pending = await sourceDetail(context, receivables[0].feeSourceId) record(context, '保留费用待审核', pending.response, - pending.value?.reviewStatus === 'PENDING' && pending.value?.settlementIncluded === false, + pending.value?.reviewStatus === 'PENDING', `feeSourceId=${receivables[0].feeSourceId}, review=${pending.value?.reviewStatus}`) matrix.feeSources.pendingReview = pending.value - matrix.feeSources.approvedUnbilled = await approveAndInclude(context, receivables[1], 'approved-unbilled') + matrix.feeSources.approvedUnbilled = await approveSource(context, receivables[1], 'approved-unbilled') const statementRoles = [ ['draft', 'DRAFT'], ['pendingReview', 'PENDING_REVIEW'], ['arUnpaid', 'ISSUED'], @@ -322,7 +321,7 @@ export async function run(context) { ] for (let index = 0; index < statementRoles.length; index += 1) { const [role, status] = statementRoles[index] - await approveAndInclude(context, receivables[index + 2], role) + await approveSource(context, receivables[index + 2], role) const statement = await createStatement(context, receivables[index + 2], role, status) if (statement.value) matrix.statements[role] = statement.value } @@ -344,7 +343,7 @@ export async function run(context) { } for (const [index, role] of ['apUnpaid', 'apPartial', 'apPaid'].entries()) { - await approveAndInclude(context, payables[index], role) + await approveSource(context, payables[index], role) const settlement = await createAp(context, fixture, payables[index], role) if (!settlement) continue if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs new file mode 100644 index 000000000..7c9f832f6 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { EXPORT_SPECS, REQUIRED_PERMISSIONS, run } from './finance-downloads.mjs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-downloads.mjs'), 'utf8') + +test('八类 Finance XLSX 契约均有正式表头并在完整链末尾执行', () => { + assert.deepEqual(REQUIRED_PERMISSIONS, [ + 'platform:download:list', 'platform:finance:customer-account:export', + 'platform:finance:customer-arap:export', 'platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export', 'platform:finance:settlement-ap:export', + ]) + assert.deepEqual(Object.keys(EXPORT_SPECS), [ + 'FINANCE_CUSTOMER_ACCOUNT_LIST', 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', 'FINANCE_FEE_SOURCE_LIST', + 'FINANCE_RECONCILIATION_STATEMENT_LIST', 'FINANCE_RECEIVABLE_SETTLEMENT_LIST', + 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', 'FINANCE_PAYABLE_SETTLEMENT_LIST', + 'FINANCE_PAYABLE_SETTLEMENT_DETAIL', + ]) + assert.ok(Object.values(EXPORT_SPECS).every((headers) => headers.length >= 9)) + const order = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) + assert.deepEqual(order.slice(-4), [ + 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', + ]) +}) + +test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁和失败信息', () => { + assert.match(source, /fixture\.marker !== context\.marker/) + assert.match(source, /parseXlsx\(download\.bytes\)/) + assert.match(source, /processedRows/) + assert.match(source, /60 秒同参请求合并/) + assert.match(source, /同用户并发导出门禁/) + assert.match(source, /无 Finance export 权限拒绝创建/) + assert.match(source, /Provider 失败信息可见/) + assert.match(source, /9223372036854775806/) + assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/) +}) + +test('未部署时能力探测后记录部署前置,不借历史业务数据', async () => { + const steps = []; const gaps = [] + const context = { + marker: 'ADMIN-API-PROBE', createdResources: {}, + apiSuccess(module, name, response) { + const passed = response.http === 200 && String(response.body?.code) === '200' + steps.push({ module, name, passed }); return passed ? response.body.data : null + }, + assert(module, name) { steps.push({ module, name, passed: false }) }, + addStep(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + async request(method, path) { + if (path.endsWith('/authorization/permissions')) return { http: 200, body: { code: '200', data: REQUIRED_PERMISSIONS } } + return { http: 200, body: { code: 'A0403', message: 'not deployed' } } + }, + } + await run(context) + assert.equal(context.createdResources.financeExportTasks.marker, context.marker) + assert.equal(gaps.length, 1) + assert.match(gaps[0][3], /部署后重跑完整链/) + assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false) +}) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs new file mode 100644 index 000000000..a851aa6ce --- /dev/null +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -0,0 +1,282 @@ +import { inflateRawSync } from 'node:zlib' + +const MODULE = 'finance-downloads' +const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'EXPIRED']) +const OPTIONS = { scenario: 'finance-download-center', deploymentBlocker: true } +export const REQUIRED_PERMISSIONS = Object.freeze([ + 'platform:download:list', + 'platform:finance:customer-account:export', + 'platform:finance:customer-arap:export', + 'platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export', + 'platform:finance:settlement-ap:export', +]) + +export const EXPORT_SPECS = Object.freeze({ + FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'], + FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'], + FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '参与结算', '来源状态', '已生成对账单'], + FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'], + FINANCE_RECEIVABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'], + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '状态'], + FINANCE_PAYABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'], + FINANCE_PAYABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '状态'], +}) + +function id(value) { return value == null ? '' : String(value) } +function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function values(value) { + if (Array.isArray(value)) return value + for (const key of ['records', 'data', 'items', 'list']) if (Array.isArray(value?.[key])) return value[key] + return [] +} +function xml(value) { + return String(value ?? '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/'/g, "'").replace(/&/g, '&') +} +function columnIndex(reference) { + const letters = reference.match(/[A-Z]+/i)?.[0]?.toUpperCase() || 'A' + let result = 0 + for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64 + return result - 1 +} + +function zipEntries(bytes) { + const buffer = Buffer.from(bytes) + let end = -1 + for (let index = buffer.length - 22; index >= Math.max(0, buffer.length - 65_557); index -= 1) { + if (buffer.readUInt32LE(index) === 0x06054b50) { end = index; break } + } + if (end < 0) throw new Error('XLSX ZIP central directory is missing') + const count = buffer.readUInt16LE(end + 10) + let offset = buffer.readUInt32LE(end + 16) + const entries = new Map() + for (let index = 0; index < count; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('XLSX ZIP entry is invalid') + const method = buffer.readUInt16LE(offset + 10) + const compressedSize = buffer.readUInt32LE(offset + 20) + const nameLength = buffer.readUInt16LE(offset + 28) + const extraLength = buffer.readUInt16LE(offset + 30) + const commentLength = buffer.readUInt16LE(offset + 32) + const localOffset = buffer.readUInt32LE(offset + 42) + const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8') + const localNameLength = buffer.readUInt16LE(localOffset + 26) + const localExtraLength = buffer.readUInt16LE(localOffset + 28) + const start = localOffset + 30 + localNameLength + localExtraLength + const compressed = buffer.subarray(start, start + compressedSize) + entries.set(name, method === 8 ? inflateRawSync(compressed) : Buffer.from(compressed)) + offset += 46 + nameLength + extraLength + commentLength + } + return entries +} + +export function parseXlsx(bytes) { + const entries = zipEntries(bytes) + const shared = [] + const sharedXml = entries.get('xl/sharedStrings.xml')?.toString('utf8') || '' + for (const match of sharedXml.matchAll(/]*>([\s\S]*?)<\/si>/g)) { + shared.push(xml([...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => part[1]).join(''))) + } + const sheet = entries.get('xl/worksheets/sheet1.xml')?.toString('utf8') + if (!sheet) throw new Error('XLSX sheet1 is missing') + const rows = [] + for (const rowMatch of sheet.matchAll(/]*>([\s\S]*?)<\/row>/g)) { + const row = [] + for (const cell of rowMatch[1].matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const reference = cell[1].match(/\br="([^"]+)"/)?.[1] || 'A1' + const type = cell[1].match(/\bt="([^"]+)"/)?.[1] + const raw = cell[2].match(/([\s\S]*?)<\/v>/)?.[1] + const inline = [...cell[2].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => part[1]).join('') + row[columnIndex(reference)] = type === 's' ? shared[Number(raw)] : xml(inline || raw || '') + } + rows.push(row.map((value) => value ?? '')) + } + return { headers: rows[0] || [], rows: rows.slice(1) } +} + +async function poll(context, taskId, expected = 'SUCCEEDED', attempts = 45) { + let response = null; let task = null; const statuses = [] + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0) await wait(2_000) + response = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`) + if (response.http === 200 && String(response.body?.code) === '200') { + task = response.body.data + if (task?.status && statuses.at(-1) !== task.status) statuses.push(task.status) + if (TERMINAL.has(task?.status)) break + } + } + context.addStep(MODULE, `任务 ${taskId} 到达 ${expected}`, response, task?.status === expected, + `status=${task?.status ?? 'missing'}, transitions=${statuses.join('>') || 'missing'}, failure=${task?.failureMessage || 'none'}`, OPTIONS) + return { response, task, statuses } +} + +async function createTask(context, type, parameters, suffix, expectedHeaders, expectedTokens = []) { + const body = { exportType: type, format: 'XLSX', taskName: `${context.marker}-${suffix}`, parameters } + const createdResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const created = context.apiSuccess(MODULE, `${type} 创建导出任务`, createdResponse, + `type=${type}`, OPTIONS) + const taskId = id(created?.id) + context.assert(MODULE, `${type} 创建态与长 ID 参数正确`, createdResponse, + Boolean(taskId) && created?.exportType === type && created?.status === 'PENDING' + && Object.values(parameters).every((value) => typeof value !== 'number' || Number.isSafeInteger(value)), + `taskId=${taskId || 'missing'}, status=${created?.status ?? 'missing'}`, OPTIONS) + if (!taskId) return null + + const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) + context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, + `original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) + + const listResponse = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=100&exportType=${type}`) + const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS) + context.assert(MODULE, `${type} 列表包含本轮任务`, listResponse, + values(page).some((item) => id(item.id) === taskId && item.taskName === body.taskName), `taskId=${taskId}`, OPTIONS) + + const final = await poll(context, taskId) + if (final.task?.status !== 'SUCCEEDED') return { taskId, fileId: null, status: final.task?.status } + const task = final.task + context.assert(MODULE, `${type} 文件元数据完整`, final.response, + task.fileName?.endsWith('.xlsx') && task.contentType?.includes('spreadsheetml') && Number(task.sizeBytes) > 0 + && Number.isFinite(Number(task.processedRows)), + `fileName=${task.fileName || 'missing'}, bytes=${task.sizeBytes ?? 'missing'}, rows=${task.processedRows ?? 'missing'}`, OPTIONS) + const download = await context.requestBinary('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}/download`) + let workbook = null + try { workbook = download.http === 200 ? parseXlsx(download.bytes) : null } catch (error) { + context.addStep(MODULE, `${type} 解析 XLSX`, download, false, error instanceof Error ? error.message : String(error), OPTIONS) + } + context.addStep(MODULE, `${type} 真实文件下载`, download, + download.http === 200 && download.bytes?.[0] === 0x50 && download.bytes?.[1] === 0x4b, + `bytes=${download.bytes?.length ?? 0}, contentType=${download.contentType || 'missing'}`, OPTIONS) + if (workbook) { + context.assert(MODULE, `${type} XLSX 表头`, download, + JSON.stringify(workbook.headers) === JSON.stringify(expectedHeaders), `headers=${workbook.headers.join('|')}`, OPTIONS) + const flattened = workbook.rows.flat().map(String) + context.assert(MODULE, `${type} XLSX 行数`, download, + workbook.rows.length === Number(task.processedRows), `sheetRows=${workbook.rows.length}, processedRows=${task.processedRows}`, OPTIONS) + context.assert(MODULE, `${type} 业务键与筛选结果`, download, + expectedTokens.every((token) => flattened.includes(String(token))), + `expected=${expectedTokens.join('|') || 'empty-result'}, rows=${workbook.rows.length}`, OPTIONS) + } + return { taskId, fileId: null, status: task.status, fileName: task.fileName, + downloadPath: `/api/platform/v1/files/export-tasks/${taskId}/download`, processedRows: Number(task.processedRows) } +} + +async function official(context, path, name) { + const response = await context.request('GET', path) + return { response, data: context.apiSuccess(MODULE, name, response, '', OPTIONS) } +} + +async function noPermission(context) { + const username = process.env.MERCHANT_TEST_USERNAME + const password = process.env.MERCHANT_TEST_PASSWORD + if (!username || !password) { + context.addCoverageGap(MODULE, 'finance-download-center', '无导出权限拒绝', '本轮商户验收账号前置未生成', 'prerequisite') + return + } + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const token = login.http === 200 && String(login.body?.code) === '200' ? login.body.data?.accessToken : '' + if (!token) { + context.addStep(MODULE, '无权限账号登录', login, false, '商户 Token 未生成', OPTIONS); return + } + const denied = await context.request('POST', '/api/platform/v1/files/export-tasks', { + exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', taskName: `${context.marker}-DENIED`, parameters: {}, + }, token) + context.addStep(MODULE, '无 Finance export 权限拒绝创建', denied, + denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code)), + `HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}`, OPTIONS) +} + +export async function run(context) { + context.createdResources.financeExportTasks = { marker: context.marker, tasks: {} } + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') + const permissionData = context.apiSuccess(MODULE, 'fresh login 权限快照', permissionsResponse, '', OPTIONS) + const permissions = new Set(Array.isArray(permissionData) ? permissionData : []) + const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !permissions.has(permission)) + context.assert(MODULE, '下载中心与五项 Finance export 权限齐全', permissionsResponse, + missingPermissions.length === 0, `missing=${missingPermissions.join(',') || 'none'}`, OPTIONS) + if (missingPermissions.length > 0) { + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', + `fresh login 权限快照缺失:${missingPermissions.join(',')}`, 'deployment-prerequisite') + return + } + const longMissingCustomerId = '9223372036854775806' + const probe = await createTask(context, 'FINANCE_CUSTOMER_ACCOUNT_LIST', + { customerId: longMissingCustomerId }, 'CAPABILITY-PROBE', EXPORT_SPECS.FINANCE_CUSTOMER_ACCOUNT_LIST) + context.createdResources.financeExportTasks.probe = probe + if (!probe || probe.status !== 'SUCCEEDED') { + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', + 'Finance/File/权限/配置尚未完整部署,能力探测未成功;部署后重跑完整链', 'deployment-prerequisite') + return + } + context.assert(MODULE, '长 ID 字符串与空结果', null, + probe.processedRows === 0, `customerIdType=string, customerId=${longMissingCustomerId}, rows=${probe.processedRows}`, OPTIONS) + await noPermission(context) + + const fixture = context.createdResources.financeFullFixtures + if (!fixture || fixture.marker !== context.marker) { + context.addStep(MODULE, 'Finance 本轮 fixture 前置', null, false, + `expected marker=${context.marker}, actual=${fixture?.marker || 'missing'};禁止借历史数据`, OPTIONS) + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 导出业务数据校验', + '缺少同 marker 的 financeFullFixtures;请使用完整 Finance 链运行', 'prerequisite') + return + } + const customerId = id(fixture.customerId) + const statementId = id(fixture.statements?.find((item) => item.statementId)?.statementId) + const arId = id(fixture.receivableSettlements?.find((item) => item.settlementId)?.settlementId) + const apId = id(fixture.payableSettlements?.find((item) => item.settlementId)?.settlementId) + if (!customerId || !statementId || !arId || !apId) throw new Error('Finance download fixtures are incomplete') + + const account = await official(context, `/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`, '读取客户账户筛选事实') + const accountRow = values(account.data)[0] + const ledger = await official(context, `/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`, '读取账户流水事实') + const statement = await official(context, `/api/platform/finance/v1/reconciliation-statements/${encodeURIComponent(statementId)}`, '读取对账单导出事实') + const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实') + const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实') + const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实') + const feeRow = values(fee.data)[0] + const statementNo = statement.data?.statementNo + const arSummary = ar.data?.summary || ar.data + const apSummary = ap.data?.summary || ap.data + const ledgerKey = values(ledger.data)[0]?.ledgerId + + const runs = [ + ['FINANCE_CUSTOMER_ACCOUNT_LIST', { customerId, ...(accountRow?.status ? { status: accountRow.status } : {}) }, [accountRow?.customerName]], + ['FINANCE_CUSTOMER_ACCOUNT_LEDGER', { customerId }, [ledgerKey]], + ['FINANCE_FEE_SOURCE_LIST', { customerId, ...(feeRow?.direction ? { direction: feeRow.direction } : {}) }, [feeRow?.sourceId]], + ['FINANCE_RECONCILIATION_STATEMENT_LIST', { customerId, status: statement.data?.status, billNo: statementNo }, [statementNo]], + ['FINANCE_RECEIVABLE_SETTLEMENT_LIST', { customerId, status: arSummary?.status, settlementNo: arSummary?.settlementNo }, [arSummary?.settlementNo]], + ['FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', { settlementId: arId }, [arSummary?.settlementNo]], + ['FINANCE_PAYABLE_SETTLEMENT_LIST', { customerId, status: apSummary?.status, settlementNo: apSummary?.settlementNo, + ...(apSummary?.supplierName ? { supplierName: apSummary.supplierName } : {}) }, [apSummary?.settlementNo]], + ['FINANCE_PAYABLE_SETTLEMENT_DETAIL', { settlementId: apId }, [apSummary?.settlementNo]], + ] + for (const [type, parameters, tokens] of runs) { + const cleanTokens = tokens.filter((value) => value != null && String(value) !== '') + context.createdResources.financeExportTasks.tasks[type] = await createTask(context, type, parameters, + type.replace('FINANCE_', ''), EXPORT_SPECS[type], cleanTokens) + } + + const concurrentResponses = await Promise.all(Array.from({ length: 4 }, (_, index) => context.request('POST', + '/api/platform/v1/files/export-tasks', { + exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', + taskName: `${context.marker}-CONCURRENCY-${index + 1}`, + parameters: { customerId, unsupportedAcceptanceKey: `${context.marker}-${index + 1}` }, + }))) + const concurrentTasks = concurrentResponses + .filter((response) => response.http === 200 && String(response.body?.code) === '200') + .map((response) => response.body.data) + const limited = concurrentResponses.some((response) => response.http === 429 + || ['429', 'A0429'].includes(String(response.body?.code))) + context.addStep(MODULE, '同用户并发导出门禁', concurrentResponses.find((response) => response.http === 429) || concurrentResponses.at(-1), + concurrentTasks.length <= 3 && limited, + `created=${concurrentTasks.length}, limited=${limited}, codes=${concurrentResponses.map((response) => `${response.http}/${response.body?.code ?? 'missing'}`).join(',')}`, OPTIONS) + const failureTask = concurrentTasks[0] + if (failureTask?.id) { + const failed = await poll(context, id(failureTask.id), 'FAILED') + context.assert(MODULE, 'Provider 失败信息可见', failed.response, + Boolean(failed.task?.failureMessage), `failureMessage=${failed.task?.failureMessage || 'missing'}`, OPTIONS) + context.createdResources.financeExportTasks.providerFailure = { + taskId: id(failureTask.id), fileId: null, status: failed.task?.status, failureMessage: failed.task?.failureMessage, + } + } +} diff --git a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs index 1a18d9957..f07805750 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs @@ -46,7 +46,7 @@ test('writes carry marker idempotency and include replay and stale-version confl assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/) }) -test('uses existing ACTIVE partner and never creates one', () => { - assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/) - assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/) +test('uses existing ACTIVE Warehouse supplier and never creates one', () => { + assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/) + assert.doesNotMatch(source, /\/api\/platform\/v1\/partners/) }) diff --git a/scripts/admin-test-env-modules/finance-extended-fees.mjs b/scripts/admin-test-env-modules/finance-extended-fees.mjs index 43f586dbf..f9834b046 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees.mjs @@ -34,23 +34,24 @@ async function loadFeeItem(context, direction) { const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS) return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' - ? item.warehouseReceivable === true : item.warehousePayable === true)) || null + ? item.warehouseReceivable === true + : item.warehousePayable === true)) || null } -async function loadActivePartner(context) { - const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE') - const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS) - return rows(data).find((item) => item?.status === 'ACTIVE') || null +async function loadActiveSupplier(context, category) { + const suffix = category ? `&category=${encodeURIComponent(category)}` : '' + const response = await context.request('GET', `/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE${suffix}`) + const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 供应商', response, category ? `category=${category}` : 'all categories', OPTIONS) + return rows(data).find((item) => item?.status === 'ACTIVE' && (!category || item?.category === category)) || null } -async function createFee(context, resource, item, spec, partner) { +async function createFee(context, resource, item, spec, supplier) { const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details` const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}` const body = { feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate, - unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true, - partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null, - partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null, + unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, + supplierId: spec.direction === 'PAYABLE' ? String(supplier.id) : null, remark: `${FIXED_MARKER}-${spec.name}`, } const response = await request(context, 'POST', path, body, `create-${spec.name}`) @@ -126,10 +127,10 @@ export async function run(context) { const receivableItem = await loadFeeItem(context, 'RECEIVABLE') const payableItem = await loadFeeItem(context, 'PAYABLE') - const partner = await loadActivePartner(context) - if (!receivableItem || !payableItem || !partner?.id) { + const supplier = await loadActiveSupplier(context, payableItem?.supplierCategory || null) + if (!receivableItem || !payableItem || !supplier?.id) { blocker(context, '费用状态矩阵前置主数据完整', - `receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`) + `receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activeSupplier=${id(supplier?.id) || 'missing'}`) return } @@ -145,7 +146,7 @@ export async function run(context) { for (const [index, spec] of specs.entries()) { const resource = resources[index % resources.length] const fixture = await createFee(context, resource, - spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner) + spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, supplier) if (!fixture) continue if (spec.action === 'reject') { const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`, @@ -167,8 +168,7 @@ export async function run(context) { const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED') if (conflictTarget) { const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate, - unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true, - partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` } + unitPrice: 115, currency: 'CNY', amount: 115, remark: `${FIXED_MARKER}-CONFLICT` } const conflict = await request(context, 'POST', `${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`, adjustBody, 'stale-adjust') @@ -181,8 +181,7 @@ export async function run(context) { if (replayTarget) { const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}` const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate, - unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true, - partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` } + unitPrice: 116, currency: 'CNY', amount: 116, remark: `${FIXED_MARKER}-IDEMPOTENT` } const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') const firstData = first?.body?.data; const secondData = second?.body?.data diff --git a/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs index d66515b9e..576335d13 100644 --- a/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs @@ -46,7 +46,9 @@ test('扩展财务验收输出可回传资源与柜次部署阻断', () => { assert.match(source, /containerPayableDeploymentBlocker/) }) -test('按柜 PAYABLE 费用携带有效往来单位', () => { - assert.match(fixtureSource, /partnerId: id\(supplier\.id\)/) - assert.match(fixtureSource, /partnerName: supplier\.partnerName \|\| null/) +test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => { + assert.match(fixtureSource, /supplierId: id\(supplier\.id\)/) + assert.doesNotMatch(fixtureSource, /partnerId|partnerName/) + assert.match(fixtureSource, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) + assert.match(fixtureSource, /!category \|\| detail\?\.category === category/) }) diff --git a/scripts/admin-test-env-modules/finance-extended-settlement.mjs b/scripts/admin-test-env-modules/finance-extended-settlement.mjs index 897575295..efa54d86f 100644 --- a/scripts/admin-test-env-modules/finance-extended-settlement.mjs +++ b/scripts/admin-test-env-modules/finance-extended-settlement.mjs @@ -49,16 +49,17 @@ async function runOwnedFixture(context) { } async function loadPayableInputs(context) { - const [feeItemsResponse, partnersResponse] = await Promise.all([ + const [feeItemsResponse, suppliersResponse] = await Promise.all([ context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'), - context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE'), + context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING'), ]) const feeItems = rows(data(feeItemsResponse)) - const partners = rows(data(partnersResponse)) + const suppliers = rows(data(suppliersResponse)) return { - feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true) || null, - supplier: partners.find((item) => item?.status === 'ACTIVE' && item?.id) || null, - response: !ok(feeItemsResponse) ? feeItemsResponse : partnersResponse, + feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true + && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`)) || null, + supplier: suppliers.find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING' && item?.id) || null, + response: !ok(feeItemsResponse) ? feeItemsResponse : suppliersResponse, } } @@ -67,8 +68,8 @@ async function createWaybillPayable(context, fixture, order, input, index) { if (!inboundId) return null const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}` const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index), - unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, - partnerId: id(input.supplier.id), partnerName: input.supplier.partnerName || null, + unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', + supplierId: id(input.supplier.id), remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` } const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`) const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response, @@ -82,7 +83,7 @@ async function createWaybillPayable(context, fixture, order, input, index) { return { sourceFeeId, inboundId, orderId: id(order.orderId), waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo), businessDate: body.businessDate, amount: body.amount, - supplierId: id(input.supplier.id), supplierName: input.supplier.partnerName || null } + supplierId: id(input.supplier.id), supplierName: input.supplier.name || null } } async function resolvePoolSources(context, fixture, created) { @@ -261,7 +262,7 @@ export async function run(context) { const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId, direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL', - marker: context.marker, partnerId: pool.resolved[0].supplierId } + marker: context.marker, supplierId: pool.resolved[0].supplierId } await runReconciliation(context, fixture, legacyPayable) const receivables = fixture.receivableSettlements || [] const firstPayable = fixture.payableSettlements?.[0] diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 4b8f6254c..30cf9d1e0 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -3,7 +3,8 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { run } from './finance-full-fixtures.mjs' +import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run, + supplierCategoryForFeeItem } from './finance-full-fixtures.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') @@ -30,6 +31,40 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs assert.match(source, /chargeUnit === 'CBM'/) }) +test('PAYABLE fee selection uses only the explicit nullable supplierCategory field', () => { + const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee')) + assert.match(selector, /item\.warehousePayable === true/) + assert.doesNotMatch(selector, /Boolean\(supplierCategoryForFeeItem/) + assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'BOOKING', feeTypeName: '操作费' }), 'BOOKING') + assert.equal(supplierCategoryForFeeItem({ supplierCategory: null, feeTypeName: '拖车费' }), null) + assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'INVALID' }), null) +}) + +test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => { + const body = buildSupplierBody('ADMIN-API-20260815-FINANCE', 'BOOKING') + assert.equal(body.category, 'BOOKING') + assert.equal(Object.hasOwn(body, 'scope'), false) + assert.equal(body.status, 'ACTIVE') + assert.match(body.name, /^财务验收供应商-/) + assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) + assert.match(source, /detail\?\.status === 'ACTIVE' && \(!category \|\| detail\?\.category === category\)/) +}) + +test('fee detail writes preserve 19-digit fee item IDs as strings', () => { + assert.doesNotMatch(source, /feeItemId: Number\(/) + assert.match(source, /feeItemId: String\(feeItem\.id\)/) + assert.match(source, /feeItemId: String\(feeItems\.PAYABLE\.id\)/) +}) + +test('automatic fee fixture completes the reviewed pricing workflow without recalculating', () => { + assert.match(source, /latest\?\.feeId/) + assert.doesNotMatch(source, /latest\?\.id/) + assert.match(source, /`\$\{pricingBase\}\/latest\?\$\{tenantQuery\}`/) + assert.match(source, /request\(context, 'POST', `\$\{pricingBase\}\/confirm\?\$\{tenantQuery\}`\)/) + assert.match(source, /request\(context, 'POST', `\$\{pricingBase\}\/send\?\$\{tenantQuery\}`\)/) + assert.doesNotMatch(source, /request\(context, 'POST', `\$\{pricingBase\}\/calculate/) +}) + test('shared fixture source names the finance full acceptance contract', () => { for (const field of ['marker', 'customerId', 'orders', 'feeSources', 'container', 'allocations', 'supplier', 'voucherFileId', 'deploymentBlocker']) assert.match(source, new RegExp(`\\b${field}\\b`)) for (const field of ['feeSourceId', 'sourceType', 'direction', 'currency', 'amount', 'businessDate', 'billingBasis']) { @@ -37,6 +72,28 @@ test('shared fixture source names the finance full acceptance contract', () => { } }) +test('fixture source normalization uses only marker-owned orders and real allocation order links', () => { + const sources = [ + { feeSourceId: 'F-ORDER', orderId: 'O-1', businessDate: '2026-08-10' }, + { feeSourceId: 'F-CONTAINER', billingBasis: 'CONTAINER_SHARED', businessDate: '2026-08-11' }, + ] + const orders = [ + { orderId: 'O-1', orderNo: 'ORDER-1', waybillId: 'WB-1' }, + { orderId: 'O-2', orderNo: 'ORDER-2', shipmentId: 'SHIP-2' }, + ] + const normalized = normalizeFinanceFixtureSources(sources, orders, [{ orderId: 'O-2', allocatedAmount: 30 }], + '701', 'FIN-MARKER') + + assert.deepEqual(normalized.map((source) => ({ feeSourceId: source.feeSourceId, customerId: source.customerId, + marker: source.marker, orderId: source.orderId, waybill: source.waybillId || source.shipmentId || source.orderNo })), [ + { feeSourceId: 'F-ORDER', customerId: '701', marker: 'FIN-MARKER', orderId: 'O-1', waybill: 'WB-1' }, + { feeSourceId: 'F-CONTAINER', customerId: '701', marker: 'FIN-MARKER', orderId: 'O-2', waybill: 'SHIP-2' }, + ]) + const unbound = normalizeFinanceFixtureSources([sources[1]], orders, [], '701', 'FIN-MARKER')[0] + assert.equal(unbound.orderId, null) + assert.equal(unbound.waybillId || unbound.shipmentId || unbound.orderNo, null) +}) + function fakeContext(overrides = {}) { const steps = [] const context = { @@ -61,8 +118,9 @@ function hookSet({ missing = false } = {}) { createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701', orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }), measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }), - loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }), - loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }), + loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE', + feeCode: direction === 'PAYABLE' ? 'FBA_HEAD_FREIGHT' : 'RECEIVABLE_SERVICE' }), + loadSupplier: async () => missing ? null : ({ id: 'SUPPLIER-1', name: '订舱供应商一', category: 'BOOKING', status: 'ACTIVE' }), uploadVoucher: async () => missing ? null : 'VOUCHER-1', verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }), loadPublishedAutomaticFee: async (context, resource) => { @@ -95,12 +153,17 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => ['WEIGHT_KG', 'VOLUME_CBM'].includes(value))), new Set(['WEIGHT_KG', 'VOLUME_CBM'])) assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2) - assert.equal(fixture.supplier.supplierId, 'PARTNER-1') - assert.equal(fixture.supplier.supplierName, '往来单位一') + assert.equal(fixture.supplier.supplierId, 'SUPPLIER-1') + assert.equal(fixture.supplier.supplierName, '订舱供应商一') + assert.equal(fixture.supplier.category, 'BOOKING') assert.equal(fixture.voucherFileId, 'VOUCHER-1') assert.ok(Array.isArray(fixture.allocations)) assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4) assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2) + assert.equal(fixture.feeSources.every((item) => item.feeSourceId && item.customerId === fixture.customerId + && item.businessDate && (item.waybillId || item.shipmentId || item.orderNo) && item.marker === context.marker), true) + assert.equal(fixture.feeSources.filter((item) => item.direction === 'PAYABLE').every((item) => item.supplierId + && item.supplierName && item.supplierCategory === fixture.supplier.category), true) }) test('run preserves real price-row profiles when creating automatic orders', async () => { @@ -134,7 +197,7 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou assert.deepEqual(fixture.container, { containerBookingId: null, containerNo: null }) assert.deepEqual(fixture.allocations, []) assert.ok(fixture.deploymentBlocker.length >= 3) - assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位'))) + assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('供应商'))) assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId'))) }) @@ -167,7 +230,7 @@ test('automatic fixture reuses the published review-chain source without recalcu assert.match(source, /sourceType === 'FBA_PRICING'/) assert.match(source, /reviewStatus === 'APPROVED'/) assert.match(source, /publicationStatus === 'PUBLISHED'/) - assert.match(source, /settlementIncluded === true/) + assert.doesNotMatch(source, /settlementIncluded/) assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/) assert.doesNotMatch(source, /\/fees\/calculate/) assert.doesNotMatch(source, /\/fees\/confirm/) @@ -204,6 +267,13 @@ test('fixture creates four real receivable manual sources before payable source' }) test('container cost uses loading plan identity and verifies snapshot fields before Finance', () => { + const body = buildContainerCostBody('RUN-1', '2088073097986560001', + { loadingPlanId: '2088073275689709570' }, '2088073280000000001') + assert.equal(body.customerId, '2088073097986560001') + assert.equal(body.containerId, '2088073275689709570') + assert.equal(JSON.parse(JSON.stringify(body)).customerId, '2088073097986560001') + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) assert.match(source, /containerId: String\(container\.loadingPlanId\)/) assert.doesNotMatch(source, /containerId: Number\(container\.loadingPlanId\)/) assert.match(source, /smart-loading\/plans\/\$\{encodeURIComponent\(container\.loadingPlanId\)\}/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 37b68e495..38e08b742 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -9,6 +9,41 @@ function rows(value) { return Array.isArray(value) ? value : Array.isArray(value function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) } function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) } +export function normalizeFinanceFixtureSources(sources, orders, allocations, customerId, marker) { + const orderById = new Map(rows(orders).map((order) => [id(order?.orderId ?? order?.id), order])) + const allocationOrders = rows(allocations).map((allocation) => orderById.get(id(allocation?.orderId))).filter(Boolean) + return rows(sources).map((source) => { + const sourceOrder = orderById.get(id(source?.orderId)) + || (source?.billingBasis === 'CONTAINER_SHARED' ? allocationOrders[0] : null) + return { + ...source, + customerId: id(source?.customerId ?? customerId) || null, + marker: source?.marker ?? marker, + orderId: id(source?.orderId ?? sourceOrder?.orderId ?? sourceOrder?.id) || null, + waybillId: id(source?.waybillId ?? sourceOrder?.waybillId) || null, + shipmentId: id(source?.shipmentId ?? sourceOrder?.shipmentId) || null, + orderNo: source?.orderNo ?? sourceOrder?.orderNo ?? null, + } + }) +} + +export function supplierCategoryForFeeItem(item) { + return ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'].includes(item?.supplierCategory) + ? item.supplierCategory : null +} + +export function buildSupplierBody(marker, category) { + const suffix = String(marker || '').replace(/[^0-9A-Za-z]/g, '').slice(-24) + return { + name: `财务验收供应商-${suffix}`, + category, + region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人', + phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`, + address: '财务验收地址', businessLicenseNo: `FIN${suffix}`.slice(0, 64), + licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: marker, + } +} + async function createOrder(context, suffix, pricingProfile = null) { if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile) const originalMarker = context.marker @@ -105,16 +140,17 @@ async function loadFeeItem(context, direction) { const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS) return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' - ? item.warehouseReceivable === true : item.warehousePayable === true)) || null + ? item.warehouseReceivable === true + : item.warehousePayable === true)) || null } -async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) { +async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, supplier = null) { if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate) const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details` const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}` - const body = { feeItemId: Number(feeItem.id), direction, businessDate, - unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, - partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.partnerName || null, + const body = { feeItemId: String(feeItem.id), direction, businessDate, + unitPrice: amount, currency: 'CNY', amount, + supplierId: supplier?.id ? String(supplier.id) : null, remark: `${context.marker}-${basis}` } const response = await request(context, 'POST', listPath, body) const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS) @@ -144,16 +180,40 @@ async function loadPublishedAutomaticFee(context, resource) { `服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`) return null } + const pricingBase = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees` + const tenantQuery = `tenantId=${encodeURIComponent(resource.tenantId)}` + const latestResponse = await context.request('GET', `${pricingBase}/latest?${tenantQuery}`) + let latest = context.apiSuccess(MODULE, '回读测量审核生成的自动费用试算', latestResponse, + `inboundId=${resource.inboundId}`, OPTIONS) + if (!latest?.feeId || !['CALCULATED', 'CONFIRMED', 'SENT'].includes(latest?.feeStatus)) { + blocker(context, '测量审核已生成可确认的自动费用试算', + `inboundId=${resource.inboundId}, feeId=${id(latest?.feeId) || 'missing'}, feeStatus=${latest?.feeStatus || 'missing'}, requestId=${latestResponse?.requestId || 'missing'}, traceId=${latestResponse?.traceId || 'missing'};审核只负责初算,Finance fixture 不重复试算`) + return null + } + if (latest.feeStatus === 'CALCULATED') { + const confirmResponse = await request(context, 'POST', `${pricingBase}/confirm?${tenantQuery}`) + latest = context.apiSuccess(MODULE, '确认测量审核生成的自动费用试算', confirmResponse, + `inboundId=${resource.inboundId}, feeId=${id(latest.feeId)}`, OPTIONS) + } + if (latest?.feeStatus === 'CONFIRMED') { + const sendResponse = await request(context, 'POST', `${pricingBase}/send?${tenantQuery}`) + latest = context.apiSuccess(MODULE, '发送已确认自动费用并发布来源', sendResponse, + `inboundId=${resource.inboundId}, feeId=${id(latest.feeId)}`, OPTIONS) + } + if (latest?.feeStatus !== 'SENT') { + blocker(context, '自动费用确认发送状态闭环', + `inboundId=${resource.inboundId}, feeId=${id(latest?.feeId) || 'missing'}, feeStatus=${latest?.feeStatus || 'missing'}`) + return null + } const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}` const listResponse = await context.request('GET', listPath) const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse, `inboundId=${resource.inboundId}`, OPTIONS) const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING' - && item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED' - && item?.settlementIncluded === true) + && item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED') if (!published) { blocker(context, '审核链已生成可结算自动费用来源', - `inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeDetails=${rows(details).length};测量审核完成链应在进入 WAIT_SPLIT 前生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源,fixture 不重复试算/确认/发布`) + `inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeId=${id(latest.feeId)}, feeStatus=${latest.feeStatus}, feeDetails=${rows(details).length};确认和发送成功后应同步生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源`) return null } const feeSourceId = id(published.sourceFeeId) @@ -174,11 +234,22 @@ async function loadPublishedAutomaticFee(context, resource) { actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } } -async function loadSupplier(context) { - if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context) - const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE') - const page = context.apiSuccess(MODULE, '查询真实供应商往来单位', response, '', OPTIONS) - return rows(page).find((item) => item?.status === 'ACTIVE') || null +async function loadSupplier(context, category) { + if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) + const effectiveCategory = category || 'BOOKING' + const body = buildSupplierBody(context.marker, effectiveCategory) + const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body) + const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response, + `category=${effectiveCategory}`, OPTIONS)) + if (!supplierId) return null + const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) + const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse, + `supplierId=${supplierId}`, OPTIONS) + context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse, + id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && (!category || detail?.category === category) + && detail?.name === body.name, + `supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS) + return detail?.status === 'ACTIVE' && (!category || detail?.category === category) ? detail : null } async function uploadVoucher(context, customerId) { @@ -262,13 +333,17 @@ async function createMarkerContainer(context, resource) { } } -async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { - if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) - const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null, +export function buildContainerCostBody(marker, customerId, container, voucherFileId) { + return { customerId: String(customerId), costType: 'CONTAINER_SHARED', orderId: null, // Finance names this field containerId, but its Firstleg cost snapshot contract // resolves it as the loading plan ID, not the physical container booking ID. - containerId: String(container.loadingPlanId), expenseName: `${context.marker}-整柜成本`, amount: 300, - currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker } + containerId: String(container.loadingPlanId), expenseName: `${marker}-整柜成本`, amount: 300, + currency: 'CNY', voucherFileIds: [voucherFileId], remark: marker } +} + +async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { + if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) + const body = buildContainerCostBody(context.marker, customerId, container, voucherFileId) const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body) if (response?.body?.code === 'FIN_400') { const first = snapshotInput?.items?.[0] @@ -322,8 +397,10 @@ export async function run(context) { RECEIVABLE: await loadFeeItem(context, 'RECEIVABLE'), PAYABLE: await loadFeeItem(context, 'PAYABLE'), } - const supplier = await loadSupplier(context) - if (!supplier?.id) blocker(context, '取得真实 PAYABLE 往来单位', '未找到 ACTIVE 往来单位;不能伪造 supplierId/partnerId') + const payableSupplierCategory = supplierCategoryForFeeItem(feeItems.PAYABLE) + const supplier = await loadSupplier(context, payableSupplierCategory) + if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商', + `未找到 ACTIVE ${payableSupplierCategory || '任意类别'} 供应商;不能伪造 supplierId`) for (const direction of ['RECEIVABLE', 'PAYABLE']) { if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`, `没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item') @@ -352,7 +429,8 @@ export async function run(context) { for (const [index, manualResource] of manualResources.entries()) { const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10, 'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier) - if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null }) + if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), + supplierName: supplier.name || null, supplierCategory: supplier.category || payableSupplierCategory }) } } @@ -364,9 +442,8 @@ export async function run(context) { if (containerOwned && feeItems.PAYABLE && supplier?.id) { const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details` const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}` - const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), - amount: 300, currency: 'CNY', settlementIncluded: true, partnerId: id(supplier.id), - partnerName: supplier.partnerName || null, remark: `${context.marker}-CONTAINER_SHARED` } + const body = { feeItemId: String(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), + amount: 300, currency: 'CNY', supplierId: id(supplier.id), remark: `${context.marker}-CONTAINER_SHARED` } const response = await request(context, 'POST', path, body) if (response?.http === 404) { blocker(context, '按柜费用来源接口已部署并可路由', @@ -377,14 +454,18 @@ export async function run(context) { if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) } containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300, businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED', - supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null, + supplierId: supplier?.id ? id(supplier.id) : null, + supplierName: supplier?.name || null, supplierCategory: supplier?.category || payableSupplierCategory, containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null, loadingPlanId: id(container.loadingPlanId) } - } else { + } else if (!containerOwned) { context.addStep(MODULE, '取得同 marker 柜次用于整柜费用分摊', null, false, '没有同 marker 的已完成柜次和可核验 containerBookingId;不读取历史柜次、不猜测柜号', OPTIONS) context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊', '当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot') + } else { + context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊', + '已识别同 marker 的 COMPLETED 柜次,但缺少 PAYABLE 费用项目或符合其显式可空类别约束的 ACTIVE 供应商', 'missing-payable-supplier') } if (containerFixture) fixtures.push(containerFixture) @@ -408,25 +489,34 @@ export async function run(context) { context.assert(MODULE, '取得订单真实运单标识', null, measured.length > 0 && measured.every((item) => item.waybillId || item.shipmentId || item.orderNo), '使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS) + const fixtureOrders = measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, + shipmentId: item.shipmentId ? id(item.shipmentId) : null, + waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId), + actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm, + businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null })) + const fixtureSources = normalizeFinanceFixtureSources(fixtures, fixtureOrders, allocations, customerId, context.marker) + const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId + || !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker + || (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName + || source.supplierCategory !== payableSupplierCategory))) + context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0, + `sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS) context.createdResources.financeFullFixtures = { marker: context.marker, customerId, prerequisites: { container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0 ? { ready: true, category: null } : { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' }, - financeSources: fixtures.length > 0 + financeSources: fixtureSources.length > 0 && invalidFixtureSources.length === 0 ? { ready: true, category: null } - : { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生可消费 Finance 费用来源' }, + : { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生字段完整的可消费 Finance 费用来源' }, }, - orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, - shipmentId: item.shipmentId ? id(item.shipmentId) : null, - waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId), - actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm, - businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null })), - feeSources: fixtures, + orders: fixtureOrders, + feeSources: fixtureSources, container: containerFixture ? { containerBookingId: containerFixture.containerBookingId, containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null }, - allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null } : null, + allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), + supplierName: supplier.name || null, category: supplier.category || payableSupplierCategory } : null, voucherFileId, pricingEvidence, deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence), } diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 2118c41b3..212c05c4b 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -7,6 +7,13 @@ import { run } from './finance-reconciliation-full.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8') + +test('Finance AP consumes only published fin_fee_source rows and never substitutes MANUAL_COST IDs', () => { + assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/) + assert.doesNotMatch(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/) + assert.match(source, /const financeSource = poolBySource\.get\(sourceId\(raw\)\)/) + assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/) +}) const financePermissions = [ 'platform:finance:customer-account:list', 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', @@ -20,7 +27,8 @@ function ok(data) { return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' } } -function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false, missingSubject = false) { +function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false, + missingSubject = false, omitPayableSource = false) { const marker = 'FIN-TEST-MARKER' const receivables = [ { feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' }, @@ -30,17 +38,18 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF ] const feeSources = [...receivables, { feeSourceId: '105', orderId: '205', direction: 'PAYABLE', currency: 'CNY', - businessDate: '2026-08-11', partnerId: '501', partnerName: 'Fixture Partner' }, + businessDate: '2026-08-11', supplierId: '501', supplierName: 'Fixture Supplier' }, { feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', - businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', + businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501', containerBookingId: 'CB-FAILED' }] feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', - businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', + businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501', containerBookingId: 'CB-COST-RESPONSE' }) const fixture = { marker, customerId: '11', orders: feeSources.filter((item) => item.orderId) .map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })), - feeSources: feeSources.map((item) => ({ ...item, marker })) } + feeSources: feeSources.map((item) => ({ ...item, customerId: '11', + waybillId: item.orderId ? `SHIP-${item.orderId}` : null, marker })) } const statements = new Map() const settlements = new Map() const idempotency = new Map() @@ -77,11 +86,17 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') { return ok(permissions) } - if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20') { - return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] }) + if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') { + return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account', + contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100, + pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', + lastChangedAt: '2026-08-10T09:00:00' }] }) } if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) { - return ok({ records: [{ customerId: '11', statementId: '1', remark: marker }] }) + return ok({ records: [{ customerId: '11', statementId: '1', ledgerId: '1', + occurredAt: '2026-08-10T09:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', + documentId: '1', documentNo: 'REC-1', receivableChange: 100, payableChange: 0, + outstandingReceivableAfter: 100, operatorName: 'operator-7001', remark: marker }] }) } if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) { return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] }) @@ -91,10 +106,10 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF requestId: 'request-denied', traceId: 'trace-denied' } } if (method === 'GET' && requestPath.includes('/fee-pool')) { - return ok(feeSources.filter((item) => item.feeSourceId).map((item, index) => { + return ok(feeSources.filter((item) => item.feeSourceId && (!omitPayableSource || item.feeSourceId !== '105')).map((item, index) => { const { feeSourceId, ...rest } = item return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId, - reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' } + reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' } })) } if (method === 'POST' && requestPath.endsWith('/statements')) { @@ -212,10 +227,14 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输 assert.match(source, /platform:finance:settlement-ar:list/) assert.match(source, /platform:finance:settlement-ap:list/) assert.doesNotMatch(source, /platform:finance:settlement-(ar|ap):view/) - assert.match(source, /\$\{BASE\}\/accounts\?page=1&pageSize=20/) + assert.match(source, /accounts\?page=1&pageSize=20&customerId=/) assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/) assert.match(source, /balance.*recharge.*prepayment/) assert.match(source, /accountReadback/) + assert.match(source, /accountInformationComplete/) + assert.match(source, /ledgerInformationComplete/) + assert.match(source, /客户账户基本信息、四项金额和最近变动时间完整/) + assert.match(source, /客户账户流水时间、业务类型、关联单据、金额和操作人完整/) for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) { assert.match(source, new RegExp(`['"]${action}['"]`)) } @@ -233,8 +252,8 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3) assert.equal(fixture.payableSettlements.length, 1) assert.equal(fixture.payableSettlements[0].supplierId, '501') - assert.equal(fixture.payableSettlements[0].partnerId, '501') - assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner') + assert.equal(fixture.payableSettlements[0].supplierId, '501') + assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Supplier') assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt' && gap.reason.includes('containerBookingId=CB-FAILED') && gap.reason.includes('containerBookingId=CB-COST-RESPONSE')), true) @@ -273,12 +292,35 @@ test('run 在 fixture 缺失时记录 blocker 且不发送 HTTP 请求', async ( assert.equal(context.coverageGaps[0].category, 'missing-marker-owned-finance-fixture') }) -test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', async () => { +test('PAYABLE 只接受 supplierId 及 Warehouse 名称快照', async () => { const context = fakeContext() await run(context) const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements')) assert.equal(payableRequest.body.supplierId, '501') - assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/) + assert.doesNotMatch(source, /partnerId|partnerName|partnerType/) +}) + +test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐', async () => { + const context = fakeContext() + delete context.createdResources.financeFullFixtures.feeSources[0].customerId + + await run(context) + + assert.equal(context.steps.some((step) => step.name === 'Fixture 来源契约完整' + && !step.passed && step.deploymentBlocker), true) + assert.equal(context.coverageGaps.some((gap) => gap.category === 'invalid-finance-fixture-contract'), true) + assert.equal(context.calls.some((call) => call.method !== 'GET'), false) +}) + +test('marker-owned PAYABLE 来源未入池时 fail-closed 且不使用 MANUAL_COST 替代', async () => { + const context = fakeContext(true, financePermissions, false, false, true) + + await run(context) + + assert.equal(context.steps.some((step) => step.name === '本轮费用来源进入 Finance 池' + && !step.passed && step.deploymentBlocker), true) + assert.equal(context.coverageGaps.some((gap) => gap.category === 'unpublished-marker-owned-finance-source'), true) + assert.equal(context.calls.some((call) => call.method !== 'GET'), false) }) test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => { diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 6e81b0659..5378d99c2 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -129,14 +129,30 @@ function accountCustomerId(value) { return id(value?.customerId ?? value?.tenant function hasWalletField(value) { return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field)) } +function accountInformationComplete(value) { + return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt'] + .every((field) => present(value?.[field])) + && ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable'] + .every((field) => Number.isFinite(Number(value?.[field]))) +} +function ledgerInformationComplete(value) { + return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName'] + .every((field) => present(value?.[field])) + && ['receivableChange', 'payableChange', 'outstandingReceivableAfter'] + .every((field) => Number.isFinite(Number(value?.[field]))) +} async function verifyCustomerAccountAndLedger(context, fixture) { const customerId = id(fixture.customerId) - const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`) + const accountResponse = await context.request('GET', + `${BASE}/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`) const accounts = accountRows(accountResponse) const account = accounts.find((item) => accountCustomerId(item) === customerId) context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse, Boolean(account) && !accounts.some(hasWalletField), `customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS) + context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse, + accountInformationComplete(account), + `customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS) const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`) const ledger = accountRows(ledgerResponse) const rowsForCustomer = ledger.filter((item) => { @@ -154,6 +170,10 @@ async function verifyCustomerAccountAndLedger(context, fixture) { context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse, rowsForCustomer.length === ledger.length && containsRoundTrip, `customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS) + const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item)) + context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse, + rowsForCustomer.length > 0 && incompleteLedger.length === 0, + `customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS) fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse), ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) } } @@ -168,7 +188,6 @@ async function normalizeFixture(context) { if (!root || root.marker !== context.marker || !root.customerId || !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null const customerId = id(root.customerId) - const orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order])) const expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean)) let pool = [] for (let attempt = 0; attempt < 20; attempt += 1) { @@ -176,22 +195,19 @@ async function normalizeFixture(context) { `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) const poolData = poolResponse?.body?.data pool = rows(poolData).concat(rows(poolData?.items)) - .filter((item) => String(item?.sourceType ?? '') !== 'MANUAL_COST') const visible = new Set(pool.map((item) => id(item?.sourceId))) if ([...expectedSourceIds].every((source) => visible.has(source))) break await new Promise((resolve) => setTimeout(resolve, 500)) } const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item])) const attemptedSources = root.feeSources.map((raw) => { - const order = orders.get(id(raw.orderId)) const financeSource = poolBySource.get(sourceId(raw)) return { - raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId), + raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId), direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(), - businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order), - marker: raw.marker ?? root.marker, - supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId), - supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null, + businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker, + supplierId: id(raw.supplierId ?? root.supplierId), + supplierName: raw.supplierName ?? root.supplierName ?? null, } }) const hasContainerAllocations = list(root.allocations).length > 0 @@ -199,9 +215,11 @@ async function normalizeFixture(context) { || (isUnboundContainerCost(source.raw) && !hasContainerAllocations)) return { root, customerId, - sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)), + sources: attemptedSources.filter((source) => source.feeSourceId + && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)), uncreatedSources, - sourceMappings: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)) + sourceMappings: attemptedSources.filter((source) => source.feeSourceId + && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)) .map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId, sourceType: source.raw?.sourceType ?? null, direction: source.direction })), } @@ -346,22 +364,32 @@ export async function run(context) { finish(context, start) return } - if (fixture.uncreatedSources.length > 0) { - const evidence = fixture.uncreatedSources.map((source) => { + const incompleteContainerSources = fixture.uncreatedSources.filter((source) => isUnboundContainerCost(source.raw)) + if (incompleteContainerSources.length > 0) { + const evidence = incompleteContainerSources.map((source) => { const raw = source.raw return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}` }).join('; ') context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试', `Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`, - 'incomplete-container-cost-attempt', { marker: context.marker, count: fixture.uncreatedSources.length }) + 'incomplete-container-cost-attempt', { marker: context.marker, count: incompleteContainerSources.length }) + } + const unpublishedSources = fixture.uncreatedSources.filter((source) => !isUnboundContainerCost(source.raw)) + if (unpublishedSources.length > 0) { + blocker(context, '本轮费用来源进入 Finance 池', + `等待 Finance fee-pool 后仍有 ${unpublishedSources.length} 条 marker-owned 来源不可见;禁止忽略来源或改用 MANUAL_COST ID`, + 'unpublished-marker-owned-finance-source') + finish(context, start) + return } const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId - || !source.businessDate || !source.waybillId || source.marker !== context.marker) + || !source.businessDate || !source.waybillId || source.marker !== context.marker + || (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName))) if (invalid.length > 0) { fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite', invalidSourceCount: invalid.length } blocker(context, 'Fixture 来源契约完整', - `存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`, + `存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker/应付供应商快照`, 'invalid-finance-fixture-contract') finish(context, start) return @@ -498,7 +526,7 @@ export async function run(context) { const body = { customerId: fixture.customerId, supplierId, feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' } const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement') - payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单', + payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 supplierId 生成应付结算单', response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS) if (payableSettlement) { const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId, @@ -515,7 +543,7 @@ export async function run(context) { } } else { blocker(context, 'PAYABLE 结算 Fixture 门禁', - `需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`, + `需要至少一条同客户 CNY PAYABLE,且全部提供相同 supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`, 'insufficient-payable-supplier-fixture') } @@ -540,7 +568,7 @@ export async function run(context) { fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) } fixture.root.payableSettlements = payableSettlement ? [{ settlementId: settlementId(payableSettlement), direction: payableSettlement.direction, - supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId), + supplierId: id(payableSettlement.supplierId), supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null, status: payableSettlement.status, paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version, diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs index f96e5dcca..79b0525ae 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { run, values } from './finance-settlement-full.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { receiptBody, run, values } from './finance-settlement-full.mjs' +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8') const response = (data, http = 200, code = '200') => ({ http, body: { code, data } }) function fakeContext(overrides = {}) { @@ -63,6 +68,16 @@ test('values helper supports nested arrays while run consumes frozen top-level a assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }]) }) +test('receipt body preserves 19-digit customer and voucher IDs through JSON', () => { + const body = receiptBody({ marker: 'RUN-1' }, '2088073097986560001', + '2088073285653299201', [{ billId: 13, amount: 1 }]) + const roundTrip = JSON.parse(JSON.stringify(body)) + assert.equal(roundTrip.customerId, '2088073097986560001') + assert.deepEqual(roundTrip.voucherFileIds, ['2088073285653299201']) + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) +}) + test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => { const context = fakeContext() await run(context) diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs index 53fb548b9..226e98d76 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -25,8 +25,8 @@ function gap(context, name, scope, reason, category = 'finance-settlement-fixtur context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker }) } -function receiptBody(context, customerId, voucherFileId, allocations) { - return { customerId: Number(customerId), receivedDate: new Date().toISOString().slice(0, 10), +export function receiptBody(context, customerId, voucherFileId, allocations) { + return { customerId: String(customerId), receivedDate: new Date().toISOString().slice(0, 10), amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations } } diff --git a/scripts/admin-test-env-modules/finance.mjs b/scripts/admin-test-env-modules/finance.mjs index 82141dad2..fe296b50e 100644 --- a/scripts/admin-test-env-modules/finance.mjs +++ b/scripts/admin-test-env-modules/finance.mjs @@ -53,19 +53,19 @@ export async function run(context) { const feeSourceId = id(source.feeSourceId || source.id) let detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}`) let detail = context.apiSuccess(MODULE, '读取本轮费用来源详情', detailResponse, '', OPTIONS) + if (detail?.reviewStatus === 'DRAFT' || detail?.reviewStatus === 'REJECTED') { + const submitBody = { reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) } + detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/submit`, submitBody, 'fee-submit') + detail = context.apiSuccess(MODULE, '提交本轮费用来源审核', detailResponse, '', OPTIONS) + } if (detail?.reviewStatus === 'PENDING') { const reviewBody = { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) } detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/review`, reviewBody, 'fee-review') detail = context.apiSuccess(MODULE, '审核本轮费用来源', detailResponse, '', OPTIONS) } - if (detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded !== true) { - const inclusionBody = { included: true, reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) } - detailResponse = await write(context, 'PUT', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/settlement-inclusion`, inclusionBody, 'fee-include') - detail = context.apiSuccess(MODULE, '将本轮费用来源纳入结算', detailResponse, '', OPTIONS) - } context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse, - detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded === true && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY', - `review=${detail?.reviewStatus}, included=${detail?.settlementIncluded}, status=${detail?.sourceStatus}`, OPTIONS) + detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY', + `review=${detail?.reviewStatus}, status=${detail?.sourceStatus}`, OPTIONS) const businessDate = detail?.businessDate || new Date().toISOString().slice(0, 10) const createBody = { customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [feeSourceId], periodStart: businessDate, periodEnd: businessDate, remark: context.marker } diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs new file mode 100644 index 000000000..05894ba14 --- /dev/null +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -0,0 +1,167 @@ +function valueAt(record, path) { + return path.split('.').reduce((value, key) => value?.[key], record) +} + +export function isPositiveId(value) { + return /^\d+$/.test(String(value ?? '')) && BigInt(String(value)) > 0n +} + +export function isNonBlank(value) { + return typeof value === 'string' && value.trim().length > 0 +} + +export function isIsoDateTime(value) { + return isNonBlank(value) && Number.isFinite(Date.parse(value)) +} + +export function isNonNegativeNumber(value) { + return value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 +} + +function gaps(record, rules) { + return Object.entries(rules) + .filter(([path, predicate]) => !predicate(valueAt(record, path), record)) + .map(([path]) => path) +} + +export function containerFieldGaps(record, { requireContainerNo = false } = {}) { + return gaps(record, { + id: isPositiveId, + cabinetNo: isNonBlank, + warehouseId: isPositiveId, + warehouseName: isNonBlank, + channelCode: isNonBlank, + containerType: isNonBlank, + capacityCbm: (value) => Number(value) > 0, + maxWeightKg: (value) => Number(value) > 0, + planStatus: isNonBlank, + ...(requireContainerNo ? { containerNo: isNonBlank } : {}), + }) +} + +export function preloadFieldGaps(record) { + return gaps(record, { + planId: isPositiveId, + containerBookingId: isPositiveId, + preloadNo: isNonBlank, + cabinetNo: isNonBlank, + customers: (value) => Array.isArray(value) && value.length > 0 + && value.every((customer) => isPositiveId(customer?.accountId) && isNonBlank(customer?.customerName)), + warehouseName: isNonBlank, + containerType: isNonBlank, + status: isNonBlank, + skuCount: isNonNegativeNumber, + totalPackageCount: isNonNegativeNumber, + totalVolumeCbm: isNonNegativeNumber, + totalGrossWeightKg: isNonNegativeNumber, + createdAt: isIsoDateTime, + }) +} + +export function loadingListFieldGaps(record) { + return gaps(record, { + planId: isPositiveId, + containerBookingId: isPositiveId, + loadingListNo: isNonBlank, + containerNo: isNonBlank, + cabinetNo: isNonBlank, + warehouseName: isNonBlank, + status: (value) => value === 'COMPLETED', + totalPackageCount: isNonNegativeNumber, + totalGrossWeightKg: isNonNegativeNumber, + totalNetWeightKg: isNonNegativeNumber, + totalVolumeCbm: isNonNegativeNumber, + totalQuantity: isNonNegativeNumber, + createdAt: isIsoDateTime, + }) +} + +export function oceanBookingFieldGaps(record) { + return gaps(record, { + id: isPositiveId, + bookingNo: isNonBlank, + status: (value) => ['DRAFT', 'RELEASED'].includes(value), + vesselName: isNonBlank, + voyageNo: isNonBlank, + estimatedSailingAt: isIsoDateTime, + estimatedArrivalAt: isIsoDateTime, + customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value), + shippingCompanyName: (value, item) => item.shippingCompanyId == null ? value == null : isNonBlank(value), + bookingAgentName: (value, item) => item.bookingAgentId == null ? value == null : isNonBlank(value), + }) +} + +export function portScheduleFieldGaps(record) { + const view = record?.portSchedule ?? record + const schedule = view?.schedule ?? view + return gaps(schedule, { + id: isPositiveId, + scheduleNo: isNonBlank, + sourceName: isNonBlank, + vesselName: isNonBlank, + voyageNo: isNonBlank, + departurePortCode: isNonBlank, + destinationPortCode: isNonBlank, + etdAt: isIsoDateTime, + etaAt: isIsoDateTime, + observedAt: isIsoDateTime, + updatedAt: isIsoDateTime, + expiresAt: isIsoDateTime, + }).concat(typeof view?.expired === 'boolean' ? [] : ['expired']) +} + +export function truckAppointmentFieldGaps(record) { + return gaps(record, { + id: isPositiveId, + appointmentNo: isNonBlank, + oceanBookingId: (value) => value == null || isPositiveId(value), + containerBookingId: (value) => value == null || isPositiveId(value), + loadingPlanId: (value) => value == null || isPositiveId(value), + warehouseId: (value) => value == null || isPositiveId(value), + plannedLoadingAt: isIsoDateTime, + status: isNonBlank, + customerId: isPositiveId, + customerName: isNonBlank, + carrierId: isPositiveId, + carrierName: isNonBlank, + attachmentCount: isNonNegativeNumber, + }) +} + +export function customsCaseFieldGaps(record) { + const customsCase = record?.customsCase ?? record?.case ?? record + return gaps(customsCase, { + id: isPositiveId, + customsNo: isNonBlank, + orderId: isPositiveId, + inboundId: isPositiveId, + containerBookingId: isPositiveId, + checklistVersionId: isPositiveId, + checklistCode: isNonBlank, + checklistVersionNo: isNonBlank, + status: isNonBlank, + updatedAt: isIsoDateTime, + }) +} + +export function outboundAggregateFieldGaps(record) { + const aggregate = record?.aggregate ?? record + return gaps(aggregate, { + orderId: isPositiveId, + inboundId: isPositiveId, + inboundCode: isNonBlank, + customerId: isPositiveId, + customerName: isNonBlank, + containerBookingId: isPositiveId, + containerNo: isNonBlank, + loadingPlanId: isPositiveId, + loadingPlanStatus: isNonBlank, + pickTaskId: isPositiveId, + pickTaskStatus: isNonBlank, + oceanBookingId: isPositiveId, + oceanBookingStatus: isNonBlank, + dispatchStatus: isNonBlank, + version: isNonNegativeNumber, + 'gate.items': (value) => Array.isArray(value), + }) +} diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs new file mode 100644 index 000000000..33330e2b0 --- /dev/null +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + containerFieldGaps, + customsCaseFieldGaps, + loadingListFieldGaps, + oceanBookingFieldGaps, + outboundAggregateFieldGaps, + portScheduleFieldGaps, + preloadFieldGaps, + truckAppointmentFieldGaps, +} from './fulfillment-list-field-contract.mjs' + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const workspace = path.resolve(repo, '..') +const read = (...parts) => fs.readFileSync(path.join(...parts), 'utf8') + +const ids = { + id: '2089000000000000001', plan: '2089000000000000002', container: '2089000000000000003', + order: '2089000000000000004', inbound: '2089000000000000005', task: '2089000000000000006', +} +const at = '2026-08-17T08:00:00' + +test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段', () => { + const source = read(workspace, 'docs', '悦', '货代SAAS平台后台', 'prototype-src', 'modules', '06-containers.html') + for (const label of ['集装箱号', '装柜清单号', '货柜状态', '柜号', '预装单号', '客户', '仓库', + '船名航次', '箱型', '总毛重', '总体积', '订舱代理', '派车单号', '运输公司', '预计装箱日期']) { + assert.match(source, new RegExp(label)) + } +}) + +test('八类 Admin 列表均以左固定操作列开头', () => { + const sources = [ + ['container-loading/pages/ContainersView.vue', 1], + ['loading-lists/components/LoadingListsView.vue', 2], + ['ocean-bookings/pages/OceanBookingsView.vue', 1], + ['port-schedules/pages/PortSchedulesView.vue', 1], + ['trucking/pages/TruckAppointmentsView.vue', 1], + ['customs/pages/CustomsCasesView.vue', 1], + ['outbound-dispatch/pages/OutboundDispatchesView.vue', 1], + ] + for (const [relative, count] of sources) { + const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative) + assert.equal([...source.matchAll(/\{ key: 'actions', label: '操作',[^\n}]*fixed: 'left'|\{ key: 'actions', label: '操作', fixed: 'left'/g)].length, count, + `${relative} operation column count`) + } +}) + +test('Admin 当前列表逐字段列与验收矩阵一致', () => { + const expectations = { + 'container-loading/pages/ContainersView.vue': ['cabinetNo', 'oceanBooking', 'containerNo', 'planStatus', 'warehouseName', 'terminalTruckingCompany', 'billOfLadingNo', 'containerType', 'shippingCompanyName', 'estimatedSailingAt', 'actualPortArrivalAt'], + 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], + 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], + 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], + 'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'], + 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], + } + for (const [relative, fields] of Object.entries(expectations)) { + const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative) + for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`) + } +}) + +test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { + const sources = [ + ['firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java', ['cabinetNo', 'containerNo', 'channelCode', 'warehouseName', 'capacityCbm', 'maxWeightKg', 'planStatus']], + ['firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java', ['record PreloadSummary', 'record ConfirmedSummary', 'totalGrossWeightKg', 'totalVolumeCbm', 'customers']], + ['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']], + ['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']], + ['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'customerName', 'carrierName', 'plannedLoadingAt', 'containerNo']], + ['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']], + ['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']], + ] + for (const [relative, fields] of sources) { + const source = read(repo, relative) + for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`) + } +}) + +test('字段校验器接受正式完整投影并精确暴露缺字段', () => { + const samples = [ + [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], + [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], + [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], + [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], + [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], + [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], + [outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'], + ] + for (const [validator, sample, options, missing] of samples) { + assert.deepEqual(validator(sample, options), []) + const invalid = structuredClone(sample) + const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid + target[missing] = '' + assert.ok(validator(invalid, options).includes(missing)) + } +}) + +test('字段断言接入正式生产模块和默认全量入口', () => { + const consumers = { + 'phase3-container-loading.mjs': ['containerFieldGaps', 'preloadFieldGaps', 'loadingListFieldGaps'], + 'ocean-bookings.mjs': ['oceanBookingFieldGaps'], + 'phase3-port-schedules.mjs': ['portScheduleFieldGaps'], + 'truck-appointments.mjs': ['truckAppointmentFieldGaps'], + 'phase3-customs.mjs': ['customsCaseFieldGaps'], + 'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'], + } + for (const [file, tokens] of Object.entries(consumers)) { + const source = read(repo, 'scripts', 'admin-test-env-modules', file) + for (const token of tokens) assert.match(source, new RegExp(token), `${file}: ${token}`) + } + const entry = read(repo, 'scripts', 'admin-test-env-acceptance.mjs') + for (const module of Object.keys(consumers).map((file) => file.replace(/\.mjs$/, ''))) { + assert.match(entry, new RegExp(`['"]${module}['"]`), `default entry: ${module}`) + } +}) + +test('矩阵登记 LANDED 与 UNKNOWN 且禁止 fixture 伪造未知字段', () => { + const matrix = read(workspace, 'docs', '三期', '货柜履约原型字段验收矩阵-2026-08-17.md') + assert.match(matrix, /LANDED/) + assert.match(matrix, /UNKNOWN/) + assert.match(matrix, /不得由 fixture 伪造|不生成假值/) + for (const label of ['货柜', '预装清单', '装柜清单', '订舱', '船期', '拖车', '报关协同', '国内出仓']) { + assert.match(matrix, new RegExp(label)) + } +}) diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs new file mode 100644 index 000000000..d1b4fe621 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const entry = new URL('../admin-test-env-acceptance.mjs', import.meta.url) +const moduleFile = new URL('./inbound-fees.mjs', import.meta.url) + +test('admin full acceptance registers inbound fee lifecycle after measurement', async () => { + const source = await readFile(entry, 'utf8') + assert.match(source, /'inbound-measurement', 'inbound-fees', 'phase3-container-loading'/) + assert.match(source, /normalized\.includes\('inbound-fees'\)/) + assert.match(source, /\['merchant-qualification-fixture', 'orders', 'inbound-measurement'\]/) +}) + +test('inbound fee lifecycle covers frozen source, version, publication and WAIT_SPLIT contracts', async () => { + const source = await readFile(moduleFile, 'utf8') + for (const token of [ + '/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details', + 'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED', + 'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'WAIT_SPLIT', + ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(source, /人工发布头程费用并触发 Finance Outbox/) + assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/) + assert.doesNotMatch(source, /settlementIncluded|是否结算|参与结算/) +}) diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs new file mode 100644 index 000000000..eb8073179 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -0,0 +1,189 @@ +const MODULE = 'inbound-fees' +const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true } + +function id(value) { return value === null || value === undefined ? '' : String(value) } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function money(value) { return Number(value) } +function requestHeaders(key) { return { 'Idempotency-Key': key.slice(0, 128) } } + +function finish(context, start) { + const steps = context.steps.slice(start) + context.scenarios.push({ + name: OPTIONS.scenario, + module: MODULE, + success: steps.every((step) => step.passed), + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) +} + +async function listDetails(context, tenantId, inboundId, name) { + const response = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(tenantId)}`) + const rows = context.apiSuccess(MODULE, name, response, '', OPTIONS) + return { response, rows: Array.isArray(rows) ? rows : [] } +} + +export async function run(context) { + const start = context.steps.length + const fixture = context.createdResources.inboundMeasurement + const tenantId = id(fixture?.tenantId) + const inboundId = id(fixture?.inboundId) + context.assert(MODULE, '复用本轮审核通过且进入 WAIT_SPLIT 的入库单', null, + Boolean(tenantId && inboundId && fixture?.finalStatus === 'APPROVED' && fixture?.processStatus === 'WAIT_SPLIT'), + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, measurement=${fixture?.finalStatus || 'missing'}, process=${fixture?.processStatus || 'missing'}`, OPTIONS) + if (!tenantId || !inboundId) { finish(context, start); return } + + const latestPath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/latest?tenantId=${encodeURIComponent(tenantId)}` + const latestResponse = await context.request('GET', latestPath) + const initial = context.apiSuccess(MODULE, '审核成功后读取自动初算费用', latestResponse, '', OPTIONS) + context.assert(MODULE, '自动初算保持 WAIT_SPLIT 且尚未发布', latestResponse, + initial?.feeStatus === 'CALCULATED' && initial?.nextProcessStatus === 'WAIT_SPLIT' + && Number(initial?.feeVersion) >= 1 && money(initial?.totalAmount) >= 0, + `feeId=${id(initial?.feeId) || 'missing'}, version=${initial?.feeVersion ?? 'missing'}, status=${initial?.feeStatus || 'missing'}, process=${initial?.nextProcessStatus || 'missing'}`, OPTIONS) + if (!initial?.feeId || initial?.feeStatus !== 'CALCULATED') { finish(context, start); return } + + const initialDetails = await listDetails(context, tenantId, inboundId, '查询自动初算派生费用明细') + const initialDerived = initialDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(initial.feeId)) + context.assert(MODULE, 'FBA_PRICING 明细是初算版本的只读草稿派生', initialDetails.response, + initialDerived?.sourceFeeVersion === initial.feeVersion && initialDerived?.reviewStatus === 'PENDING' + && initialDerived?.publicationStatus === 'DRAFT' && initialDerived?.direction === 'RECEIVABLE' + && money(initialDerived?.amount) === money(initial?.totalAmount), + `sourceType=${initialDerived?.sourceType || 'missing'}, sourceVersion=${initialDerived?.sourceFeeVersion ?? 'missing'}, review=${initialDerived?.reviewStatus || 'missing'}, publication=${initialDerived?.publicationStatus || 'missing'}`, OPTIONS) + if (initialDerived?.id) { + const derivedUpdateResponse = await context.request('PUT', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${encodeURIComponent(initialDerived.id)}?accountId=${encodeURIComponent(tenantId)}`, + { feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE', + businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice, + currency: initialDerived.currency, amount: initialDerived.amount, + remark: `${context.marker}-禁止修改派生` }) + context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse, + derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse) + && String(derivedUpdateResponse.body?.message || '').includes('只读派生'), + `code=${derivedUpdateResponse.body?.code ?? 'missing'}, message=${derivedUpdateResponse.body?.message || 'missing'}`, OPTIONS) + } + + const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01) + const adjustedUnitPrice = Math.max(0, money(initial.unitPrice)) + const adjustedAdditional = money(initial.additionalAmount) + 0.01 + const adjustResponse = await context.request('PUT', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`, + { expectedFeeVersion: Number(initial.feeVersion), chargeableQuantity: adjustedQuantity, + unitPrice: adjustedUnitPrice, additionalAmount: adjustedAdditional, + reason: `${context.marker}-自动费用版本调整` }, undefined, + requestHeaders(`${context.marker}-inbound-fee-adjust`)) + const adjusted = context.apiSuccess(MODULE, '调整初算并生成新版本', adjustResponse, '', OPTIONS) + context.assert(MODULE, '调整版本仍为 CALCULATED 且不阻塞 WAIT_SPLIT', adjustResponse, + Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED' + && adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional, + `oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS) + if (!adjusted?.feeId) { finish(context, start); return } + + const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本') + const currentDerived = adjustedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(adjusted.feeId)) + context.assert(MODULE, '派生详情切换到最新费用版本且旧版本不再当前发布', adjustedDetails.response, + currentDerived?.sourceFeeVersion === adjusted.feeVersion && currentDerived?.publicationStatus === 'DRAFT' + && !adjustedDetails.rows.some((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) !== id(adjusted.feeId) && row.publicationStatus === 'PUBLISHED'), + `derivedFeeId=${id(currentDerived?.sourceFeeId) || 'missing'}, derivedVersion=${currentDerived?.sourceFeeVersion ?? 'missing'}`, OPTIONS) + + const confirmResponse = await context.request('POST', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/confirm?tenantId=${encodeURIComponent(tenantId)}`, + undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-confirm`)) + const confirmed = context.apiSuccess(MODULE, '人工确认最新头程费用', confirmResponse, '', OPTIONS) + context.assert(MODULE, '人工确认只改变费用状态且保持 WAIT_SPLIT', confirmResponse, + confirmed?.feeStatus === 'CONFIRMED' && confirmed?.feeVersion === adjusted.feeVersion + && confirmed?.nextProcessStatus === 'WAIT_SPLIT', + `version=${confirmed?.feeVersion ?? 'missing'}, status=${confirmed?.feeStatus || 'missing'}, process=${confirmed?.nextProcessStatus || 'missing'}`, OPTIONS) + + const sendResponse = await context.request('POST', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/send?tenantId=${encodeURIComponent(tenantId)}`, + undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-send`)) + const sent = context.apiSuccess(MODULE, '人工发布头程费用并触发 Finance Outbox', sendResponse, '', OPTIONS) + const publishedDetails = await listDetails(context, tenantId, inboundId, '发布后回查对客费用派生') + const published = publishedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(sent?.feeId)) + context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse, + sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT' + && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED', + `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) + + const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE') + const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS) + const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records + : Array.isArray(feeItemsPage) ? feeItemsPage : [] + const manualItem = feeItems.find((item) => item.status === 'ACTIVE' && item.warehouseReceivable === true + && item.feeCode !== 'FBA_HEAD_FREIGHT') + context.assert(MODULE, '存在启用的非头程运费仓储应收项目', feeItemsResponse, + Boolean(manualItem?.id), `feeItemId=${id(manualItem?.id) || 'missing'}, feeCode=${manualItem?.feeCode || 'missing'}`, OPTIONS) + if (!manualItem?.id) { finish(context, start); return } + + const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` + const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE', + businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34, + remark: `${context.marker}-其他应收` } + const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload) + const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS) + context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse, + manual?.sourceType === 'MANUAL' && manual?.sourceFeeVersion === 1 + && manual?.reviewStatus === 'PENDING' && manual?.publicationStatus === 'DRAFT', + `detailId=${id(manual?.id) || 'missing'}, sourceType=${manual?.sourceType || 'missing'}, sourceVersion=${manual?.sourceFeeVersion ?? 'missing'}`, OPTIONS) + if (!manual?.id) { finish(context, start); return } + + const approveResponse = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manual.id)}/approve?accountId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS) + const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收') + const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id)) + context.assert(MODULE, '已审核 MANUAL 自动进入对客应收候选且与头程费用独立', afterApproval.response, + approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED' + && approved?.publicationStatus === 'PUBLISHED', + `review=${approved?.reviewStatus || 'missing'}, publication=${approved?.publicationStatus || 'missing'}`, OPTIONS) + + const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67, + remark: `${context.marker}-其他应收版本调整` } + const manualAdjustResponse = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manual.id)}/adjust?accountId=${encodeURIComponent(tenantId)}&expectedSourceVersion=1`, + adjustedManualPayload) + const manualV2 = context.apiSuccess(MODULE, '调整已审核 MANUAL 并生成新版本', manualAdjustResponse, '', OPTIONS) + context.assert(MODULE, 'MANUAL V2 重新进入待审核且不影响头程 SENT', manualAdjustResponse, + id(manualV2?.id) !== id(manual.id) && id(manualV2?.sourceFeeId) === id(manual.id) + && manualV2?.sourceType === 'MANUAL' && manualV2?.sourceFeeVersion === 2 + && manualV2?.reviewStatus === 'PENDING' && manualV2?.publicationStatus === 'DRAFT' + && sent?.feeStatus === 'SENT', + `sourceVersion=${manualV2?.sourceFeeVersion ?? 'missing'}, review=${manualV2?.reviewStatus || 'missing'}, freight=${sent?.feeStatus || 'missing'}`, OPTIONS) + + const currentOnly = await listDetails(context, tenantId, inboundId, '默认列表隐藏 MANUAL 已替换版本') + const historyResponse = await context.request('GET', + `${detailsBase}?accountId=${encodeURIComponent(tenantId)}&includeHistory=true`) + const historyRows = context.apiSuccess(MODULE, '显式查询费用历史版本', historyResponse, '', OPTIONS) + context.assert(MODULE, 'includeHistory 仅扩展 SUPERSEDED 历史且当前版本不重复', historyResponse, + !currentOnly.rows.some((row) => id(row.id) === id(manual.id)) + && Array.isArray(historyRows) && historyRows.some((row) => id(row.id) === id(manual.id) + && row.publicationStatus === 'SUPERSEDED') + && historyRows.some((row) => id(row.id) === id(manualV2.id) && row.sourceFeeVersion === 2), + `currentHasV1=${currentOnly.rows.some((row) => id(row.id) === id(manual.id))}, historyCount=${Array.isArray(historyRows) ? historyRows.length : 'n/a'}`, OPTIONS) + + const approveV2Response = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manualV2.id)}/approve?accountId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, '审核通过 MANUAL 新版本', approveV2Response, '', OPTIONS) + const finalDetails = await listDetails(context, tenantId, inboundId, '汇总已发布头程与已审核其他应收') + const receivableTotal = finalDetails.rows.filter((row) => row.direction === 'RECEIVABLE' + && ((row.sourceType === 'FBA_PRICING' && row.publicationStatus === 'PUBLISHED') + || (row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED'))) + .reduce((sum, row) => sum + money(row.amount), 0) + const payableTotal = finalDetails.rows.filter((row) => row.direction === 'PAYABLE' + && row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED') + .reduce((sum, row) => sum + money(row.amount), 0) + context.assert(MODULE, '对客合计仅含已发布头程与已审核其他应收且其他应付单列', finalDetails.response, + receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0, + `receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS) + + context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId, + freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id), + manualVersion: manualV2.sourceFeeVersion } + finish(context, start) +} diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 84f59e8ef..4704d790d 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -13,3 +13,16 @@ test('reprint and Mojia scan share the same frozen box code variable', () => { assert.match(source, /重复整单打印复用已冻结箱码映射[\s\S]*boxCode=\$\{boxCode\}/) assert.match(source, /const firstScanResponse = await multipartRequest[\s\S]*boxCode, \.\.\.firstDimensions/) }) + +test('measurement submission confirms weight evidence before submit', () => { + assert.match(source, /files\/measurements\/\$\{tenantId\}\/\$\{inboundId\}/) + assert.match(source, /\/weight-results\?\$\{query\(tenantId\)\}/) + assert.match(source, /\/weight-results\/\$\{created\.id\}\/confirm\?\$\{query\(tenantId\)\}/) + assert.match(source, /createConfirmedWeightEvidence[\s\S]*\/measurement\/submit/) +}) + +test('measurement fixture derives pallet resources from confirmed image count', () => { + assert.match(source, /palletCount: WEIGHING_IMAGE_COUNT/) + assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/) + assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/) +}) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index df6dd3f50..32e0da358 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -3,7 +3,8 @@ import { readFile } from 'node:fs/promises' const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const DEFAULT_PALLET_CAPACITY_CBM = 1.2 +const WEIGHING_IMAGE_COUNT = 1 +const FIXTURE_PALLET_CAPACITY_CBM = 1.2 function asId(value) { return value == null ? '' : String(value) } function asNumber(value) { return Number(value) } @@ -37,21 +38,21 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts = return { response, measurement, attempts } } -async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) { - const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM) - if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false +async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) { + if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` + const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, + weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, + }) + const palletId = asId(context.apiSuccess(MODULE, '创建称重托盘主数据', palletResponse, + `warehouseId=${warehouseId}`, OPTIONS)) + if (!palletId) return null + const resources = [] for (let index = 1; index <= palletCount; index += 1) { const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` - const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { - warehouseId, palletType: 'STANDARD', capacityCbm: DEFAULT_PALLET_CAPACITY_CBM, - weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, - }) - const palletId = asId(context.apiSuccess(MODULE, `创建测量占用托盘 ${index}/${palletCount}`, - palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) - let locationId = null if (context.context?.phase3LoadingRequireSharedOverflow !== true) { const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { @@ -63,16 +64,66 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, to locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`, locationResponse, `warehouseId=${warehouseId}`, OPTIONS)) } - if (!palletId || (context.context?.phase3LoadingRequireSharedOverflow !== true && !locationId)) return false + if (context.context?.phase3LoadingRequireSharedOverflow !== true && !locationId) return null resources.push({ palletId, locationId }) } context.createdResources.inboundWarehouseResources ??= {} context.createdResources.inboundWarehouseResources[inboundId] = { marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount, - palletCapacityCbm: DEFAULT_PALLET_CAPACITY_CBM, resources, + palletCapacityCbm: FIXTURE_PALLET_CAPACITY_CBM, resources, } - return true + return { palletId, palletCount, resources } +} + +async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { + const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url)) + const uploadResponse = await context.requestMultipart('POST', + `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { + content: image, type: 'image/png', name: `weight-${inboundId}.png`, + }) + const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, + `inboundId=${inboundId}`, OPTIONS) + const fileId = asId(upload?.fileId) + if (!fileId) return null + + const createResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { + fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, + ocrWeightValue: null, ocrWeightUnit: null, confidence: null, + }) + const created = context.apiSuccess(MODULE, '登记称重图片与托盘快照', createResponse, + `fileId=${fileId}, palletId=${palletId}`, OPTIONS) + if (!created?.id) return null + + const confirmResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { + version: Number(created.version), adjustedWeightValue: 100, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, + }) + const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse, + `resultId=${created.id}, palletId=${palletId}`, OPTIONS) + const netWeightKg = Number(confirmed?.netWeightKg) + const grossWeightKg = Number(confirmed?.grossWeightKg) + if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { + return null + } + + const workspaceResponse = await context.request('GET', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) + const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse, + `resultId=${created.id}`, OPTIONS) + const confirmedRows = (workspace?.weightResults || []) + .filter((result) => result?.recognitionStatus === 'CONFIRMED') + const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT + && asId(confirmedRows[0]?.id) === asId(created.id) + && asId(confirmedRows[0]?.palletId) === palletId + && closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001) + && closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001) + context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, + `confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, + OPTIONS) + return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null } function findIsolatedInbound(context) { @@ -230,7 +281,7 @@ export async function run(context) { const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}` // Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm). - const firstDimensions = { length: 59.1, width: 40.2, height: 30.3, weight: 12.5 } + const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 } const firstShipmentId = `FBA-${context.marker.slice(-12)}-01` const firstScanResponse = await multipartRequest(context, scanPath, { boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId, @@ -274,7 +325,7 @@ export async function run(context) { && Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1, `version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS) - const replacement = { length: 59.5, width: 41.2, height: 31.3, weight: 13.6 } + const replacement = { length: 59.5, width: 41.2, height: 31.3 } const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02` const rescanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, scannedShipmentId: replacementShipmentId, @@ -338,13 +389,12 @@ export async function run(context) { const draftPayload = { measurementId: measured.measurementId, version: Number(measured.version), - originalDocumentNo: context.marker, originalGrossWeightKg: replacement.weight * originalPackageCount, - originalVolumeCbm: totalVolume, actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, + actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, items: [{ inboundDetailId, itemNo: measuredItem.itemNo, boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount, lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height, - unitWeightKg: replacement.weight, intelligentImageFileId: measuredItem.intelligentImageFileId, + intelligentImageFileId: measuredItem.intelligentImageFileId, overLimitWarning: measuredItem.overLimitWarning ?? null, outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null, outerHeightCm: measuredItem.outerHeightCm ?? null, remark: context.marker, @@ -364,17 +414,29 @@ export async function run(context) { } const warehouseResourcesReady = await provisionWarehouseResources(context, { - inboundId, warehouseId: asId(summary?.warehouseId), totalVolumeCbm: totalVolume, + inboundId, warehouseId: asId(summary?.warehouseId), palletCount: WEIGHING_IMAGE_COUNT, }) - context.assert(MODULE, '测量提交前已创建足量真实托盘和库位', draftResponse, - warehouseResourcesReady, - `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${Math.ceil(totalVolume / DEFAULT_PALLET_CAPACITY_CBM)}`, + context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse, + Boolean(warehouseResourcesReady), + `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${WEIGHING_IMAGE_COUNT}`, OPTIONS) if (!warehouseResourcesReady) { finishScenario(context, start) return } + const weightEvidence = await createConfirmedWeightEvidence(context, { + inboundId, tenantId, palletId: warehouseResourcesReady.palletId, + }) + context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse, + Boolean(weightEvidence), + `confirmedImages=${weightEvidence?.count ?? 0}, expected=${WEIGHING_IMAGE_COUNT}`, + OPTIONS) + if (!weightEvidence) { + finishScenario(context, start) + return + } + const submitResponse = await context.request('POST', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`, { measurementId: draft.measurementId, version: Number(draft.version) }) @@ -418,14 +480,17 @@ export async function run(context) { finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED' && finalMeasurement?.measurementStatus === 'APPROVED' && Number(finalMeasurement?.actualPackageCount) === originalPackageCount + && closeTo(finalMeasurement?.actualGrossWeightKg, weightEvidence.grossWeightKg, 0.001) + && closeTo(finalMeasurement?.actualNetWeightKg, weightEvidence.netWeightKg, 0.001) && closeTo(finalMeasurement?.actualVolumeCbm, totalVolume) && closeTo(finalMeasurement?.volumeWeightKg, volumeWeight), - `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}`, OPTIONS) + `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) context.createdResources.inboundMeasurement = { marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, + palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg, } finishScenario(context, start) } diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs new file mode 100644 index 000000000..c26281c7d --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + confirmedNetSummary, + normalizedGrossKg, + selectWeightPallet, + workspaceWeightMatchesBaseline, +} from './inbound-weight-ocr.mjs' + +test('selects a stable complete pallet snapshot without status or availability', () => { + const selected = selectWeightPallet([ + { palletId: '20', palletCode: 'P-20', palletType: 'STANDARD', capacityCbm: 2, tareWeightKg: 18.5 }, + { palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5, tareWeightKg: 12 }, + ]) + assert.equal(selected.palletId, '3') + assert.equal(selectWeightPallet([]), null) + assert.equal(selectWeightPallet([ + { palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5 }, + ]), null) +}) + +test('normalizes gross weight and summarizes confirmed net weight only', () => { + assert.equal(normalizedGrossKg(50.125, 'LB'), 22.736) + assert.equal(normalizedGrossKg(30, 'KG'), 30) + assert.equal(normalizedGrossKg(30, 'UNKNOWN'), null) + assert.deepEqual(confirmedNetSummary({ weightResults: [ + { recognitionStatus: 'CONFIRMED', netWeightKg: 11.5 }, + { recognitionStatus: 'PENDING_CONFIRM', netWeightKg: 99 }, + { recognitionStatus: 'CONFIRMED', netWeightKg: 2.125 }, + ] }), { count: 2, complete: true, total: 13.625 }) +}) + +test('cleanup restores a non-empty carton fallback when there were no confirmed images', () => { + const initial = { actualGrossWeightKg: 54.4, weightResults: [] } + assert.deepEqual(confirmedNetSummary(initial), { count: 0, complete: true, total: 0 }) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: 54.4 }, 54.4), true) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, 54.4), false) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, null), true) +}) + +test('HTTP scenario uses pallet option and net weight contracts without legacy final weight', () => { + const source = readFileSync(new URL('inbound-weight-ocr.mjs', import.meta.url), 'utf8') + assert.match(source, /\/weight-pallet-options\?tenantId=/) + assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath')) + assert.match(source, /form\.append\('file'/) + assert.doesNotMatch(source, /form\.append\('palletId'/) + assert.match(source, /palletId:\s*id\(pallet\.palletId\)/) + assert.match(source, /grossWeightKg/) + assert.match(source, /netWeightKg/) + assert.match(source, /palletTareWeightKg/) + assert.doesNotMatch(source, /finalWeightKg/) + assert.doesNotMatch(source, /pallet\.(?:status|available)/) + assert.doesNotMatch(source, /platform:pallet:list/) + assert.match(source, /verifyDeleted\(context, target, original, baselineWorkspaceWeight\)/) + assert.match(source, /workspaceWeightMatchesBaseline\(cleanup\.workspace, baselineWorkspaceWeight\)/) + assert.doesNotMatch(source, /verifyDeleted\([^\n]+baselineConfirmedNet/) +}) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 96c01e29e..a0962a2d1 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -22,6 +22,47 @@ function closeNumber(actual, expected, tolerance = 0.0005) { return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance } +function palletSnapshotComplete(pallet) { + return Boolean(id(pallet?.palletId)) + && typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0 + && typeof pallet?.palletType === 'string' && pallet.palletType.length > 0 + && Number.isFinite(Number(pallet?.capacityCbm)) && Number(pallet.capacityCbm) > 0 + && Number.isFinite(Number(pallet?.tareWeightKg)) && Number(pallet.tareWeightKg) >= 0 +} + +export function selectWeightPallet(options) { + if (!Array.isArray(options)) return null + return [...options] + .filter(palletSnapshotComplete) + .sort((left, right) => id(left.palletId).localeCompare(id(right.palletId), 'en', { numeric: true }))[0] || null +} + +export function normalizedGrossKg(value, unit) { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return null + const normalizedUnit = String(unit || '').trim().toUpperCase() + if (!['KG', 'KGS', 'LB', 'LBS'].includes(normalizedUnit)) return null + const kg = ['LB', 'LBS'].includes(normalizedUnit) ? numeric * 0.45359237 : numeric + return Math.round(kg * 1000) / 1000 +} + +export function confirmedNetSummary(workspace) { + const confirmed = (workspace?.weightResults || []) + .filter((row) => row?.recognitionStatus === 'CONFIRMED') + const values = confirmed.map((row) => Number(row?.netWeightKg)) + return { + count: confirmed.length, + complete: values.every((value) => Number.isFinite(value) && value > 0), + total: Math.round(values.reduce((sum, value) => sum + value, 0) * 1000) / 1000, + } +} + +export function workspaceWeightMatchesBaseline(workspace, baselineWorkspaceWeight) { + return baselineWorkspaceWeight == null + ? workspace?.actualGrossWeightKg == null + : closeNumber(workspace?.actualGrossWeightKg, baselineWorkspaceWeight, 0.0011) +} + function expectedConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) } @@ -161,7 +202,11 @@ function deletePath(tenantId, inboundId, resultId, version) { return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}` } -async function createUploadedResult(context, target, image, overrides = {}) { +function palletOptionsPath(tenantId, inboundId) { + return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?tenantId=${encodeURIComponent(tenantId)}` +} + +async function createUploadedResult(context, target, pallet, image, overrides = {}) { const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const uploadResponse = await multipart(context, imagePath, image) const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, @@ -195,6 +240,7 @@ async function createUploadedResult(context, target, image, overrides = {}) { const payload = { fileId, + palletId: id(pallet.palletId), ocrProvider: `ADMIN_ACCEPTANCE_${context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-24)}`, ocrRawText: overrides.manual ? null : (recognized.ocrRawText || null), ocrWeightValue: overrides.manual ? null : recognized.weightValue, @@ -215,8 +261,9 @@ async function createUploadedResult(context, target, image, overrides = {}) { const resultId = id(result?.id) context.assert(MODULE, '重量结果 ID、文件 ID 和初始版本契约正确', createResponse, Boolean(resultId) && typeof result?.id === 'string' && typeof result?.fileId === 'string' - && id(result?.fileId) === fileId && Number(result?.version) === 0, - `resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, version=${result?.version ?? 'missing'}`, + && id(result?.fileId) === fileId && id(result?.palletId) === id(pallet.palletId) + && Number(result?.version) === 0, + `resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, palletId=${id(result?.palletId) || 'missing'}, version=${result?.version ?? 'missing'}`, SCENARIO_OPTIONS) if (!resultId) throw new Error(`文件 ${fileId} 已上传但重量结果登记失败,需要人工清理该测试文件`) return { upload: recognized, fileId, result, resultId, version: Number(result.version), deleted: false } @@ -267,14 +314,12 @@ async function deleteResult(context, target, item, name, options = NEW_CONTRACT_ return { response, passed } } -async function verifyDeleted(context, target, item, baselineWeight) { +async function verifyDeleted(context, target, item, baselineWorkspaceWeight) { const check = await loadWorkspace(context, target, '删除后回查重量工作区') const missing = !check.workspace?.weightResults?.some((row) => id(row.id) === item.resultId) - context.assert(MODULE, '删除后结果消失且总毛重恢复基线', check.response, - missing && (baselineWeight == null - ? check.workspace?.actualGrossWeightKg == null - : closeNumber(check.workspace?.actualGrossWeightKg, baselineWeight)), - `resultMissing=${missing}, baseline=${baselineWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`, + context.assert(MODULE, '删除后结果消失且工作区重量恢复运行前基线', check.response, + missing && workspaceWeightMatchesBaseline(check.workspace, baselineWorkspaceWeight), + `resultMissing=${missing}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}` @@ -313,12 +358,50 @@ export async function run(context) { const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区') - const baselineWeight = initial.workspace.actualGrossWeightKg + const baselineWorkspaceWeight = initial.workspace.actualGrossWeightKg + const baselineImageSummary = confirmedNetSummary(initial.workspace) const baselineIds = new Set((initial.workspace.weightResults || []).map((row) => id(row.id))) context.assert(MODULE, '测试入库单身份与基线可追踪', initial.response, id(initial.workspace.inboundId) === target.inboundId - && initial.workspace.processStatus === target.detail.summary.processStatus, - `existingResults=${baselineIds.size}, baselineWeight=${baselineWeight ?? 'null'}`, SCENARIO_OPTIONS) + && initial.workspace.processStatus === target.detail.summary.processStatus + && baselineImageSummary.complete, + `existingResults=${baselineIds.size}, confirmedImageCount=${baselineImageSummary.count}, baselineImageNetTotal=${baselineImageSummary.total}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}`, + NEW_CONTRACT_OPTIONS) + if (!baselineImageSummary.complete) { + const steps = context.steps.slice(scenarioStart) + context.createdResources.inboundWeightOcr.cleaned = true + context.scenarios.push({ + name: SCENARIO, module: MODULE, success: false, + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) + return + } + + const palletOptionsResponse = await context.request( + 'GET', palletOptionsPath(target.tenantId, target.inboundId), + ) + const palletOptions = context.apiSuccess(MODULE, '读取同仓称重托盘候选', palletOptionsResponse, + `inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS) + const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0 + && palletOptions.every(palletSnapshotComplete) + context.assert(MODULE, '托盘候选包含稳定快照字段且无需状态或占用资格', palletOptionsResponse, + palletOptionsComplete, + `count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`, + NEW_CONTRACT_OPTIONS) + const pallet = palletOptionsComplete ? selectWeightPallet(palletOptions) : null + if (!pallet) { + const steps = context.steps.slice(scenarioStart) + context.createdResources.inboundWeightOcr.cleaned = true + context.scenarios.push({ + name: SCENARIO, module: MODULE, success: false, + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) + return + } const uploadPath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const noAuth = await multipart(context, uploadPath, image, '') @@ -337,7 +420,7 @@ export async function run(context) { expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) try { - const original = await createUploadedResult(context, target, image, { retry: true }) + const original = await createUploadedResult(context, target, pallet, image, { retry: true }) created.push(original) context.createdResources.inboundWeightOcr.resultIds.push(original.resultId) await verifyImages(context, target, original, image) @@ -359,29 +442,41 @@ export async function run(context) { const recognizedValue = Number(original.result.ocrWeightValue) const recognizedUnit = original.result.ocrWeightUnit - const canConfirmOriginal = Number.isFinite(recognizedValue) && recognizedValue > 0 - && ['KG', 'KGS', 'LB', 'LBS'].includes(String(recognizedUnit || '').trim().toUpperCase()) + const recognizedGrossKg = normalizedGrossKg(recognizedValue, recognizedUnit) + const canConfirmOriginal = recognizedGrossKg != null + && recognizedGrossKg > Number(pallet.tareWeightKg) const confirmPayload = canConfirmOriginal ? { version: original.version } : { - version: original.version, adjustedWeightValue: 1.234, adjustedWeightUnit: 'KG', + version: original.version, + adjustedWeightValue: Math.round((Number(pallet.tareWeightKg) + 1.234) * 1000) / 1000, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} OCR未返回有效重量,人工验收`, } const confirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, original.resultId), confirmPayload) - const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入实际总毛重', confirmResponse, + const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入货物总重量', confirmResponse, `usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS) - context.assert(MODULE, '确认结果状态、版本和最终KG正确', confirmResponse, + const confirmedGross = Number(confirmed?.grossWeightKg) + const confirmedTare = Number(confirmed?.palletTareWeightKg) + const confirmedNet = Number(confirmed?.netWeightKg) + context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse, confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1 - && Number(confirmed?.finalWeightKg) > 0, - `status=${confirmed?.recognitionStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}, finalKg=${confirmed?.finalWeightKg ?? 'missing'}`, + && id(confirmed?.palletId) === id(pallet.palletId) + && confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType + && closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm) + && closeNumber(confirmed?.palletTareWeightKg, pallet.tareWeightKg) + && confirmedGross > confirmedTare && confirmedNet > 0 + && closeNumber(confirmedNet, confirmedGross - confirmedTare, 0.0011), + `status=${confirmed?.recognitionStatus ?? 'missing'}, palletId=${id(confirmed?.palletId) || 'missing'}, grossKg=${confirmed?.grossWeightKg ?? 'missing'}, tareKg=${confirmed?.palletTareWeightKg ?? 'missing'}, netKg=${confirmed?.netWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) original.version = Number(confirmed?.version ?? original.version) - const afterConfirm = await loadWorkspace(context, target, '确认后回查实际总毛重') - const expectedTotal = Number(baselineWeight || 0) + Number(confirmed?.finalWeightKg || 0) - context.assert(MODULE, '确认后实际总毛重按服务端结果重算', afterConfirm.response, - closeNumber(afterConfirm.workspace?.actualGrossWeightKg, expectedTotal), - `baseline=${baselineWeight ?? 'null'}, added=${confirmed?.finalWeightKg ?? 'missing'}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`, + const afterConfirm = await loadWorkspace(context, target, '确认后回查货物总重量') + const confirmedSummary = confirmedNetSummary(afterConfirm.workspace) + context.assert(MODULE, '货物总重量按全部已确认净重由服务端汇总', afterConfirm.response, + confirmedSummary.complete && confirmedSummary.count === baselineImageSummary.count + 1 + && closeNumber(afterConfirm.workspace?.actualGrossWeightKg, confirmedSummary.total, 0.0011), + `confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const staleDelete = await context.request('DELETE', @@ -389,9 +484,9 @@ export async function run(context) { context.assert(MODULE, '旧版本删除返回并发冲突', staleDelete, expectedConflict(staleDelete), `HTTP=${staleDelete.http}, code=${staleDelete.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const deletedOriginal = await deleteResult(context, target, original, '删除本轮已确认图片及重量结果') - if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWeight) + if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWorkspaceWeight) - const manual = await createUploadedResult(context, target, image, { manual: true }) + const manual = await createUploadedResult(context, target, pallet, image, { manual: true }) created.push(manual) context.createdResources.inboundWeightOcr.resultIds.push(manual.resultId) await rejectWithoutMutation(context, target, manual, '零重量确认被拒绝', { @@ -408,7 +503,7 @@ export async function run(context) { adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) - const adjustedValue = 10.125 + const adjustedValue = Math.ceil(((Number(pallet.tareWeightKg) + 5) / 0.45359237) * 1000) / 1000 const adjustedConfirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, manual.resultId), { version: manual.version, adjustedWeightValue: adjustedValue, adjustedWeightUnit: 'LB', @@ -416,16 +511,19 @@ export async function run(context) { }) const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse, `adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS) - const expectedKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000 - context.assert(MODULE, 'LB 人工改重保留输入并正确换算最终KG', adjustedConfirmResponse, + const expectedGrossKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000 + const expectedNetKg = Math.round((expectedGrossKg - Number(pallet.tareWeightKg)) * 1000) / 1000 + context.assert(MODULE, 'LB 人工改重保留毛重输入并正确换算去皮净重', adjustedConfirmResponse, adjusted?.recognitionStatus === 'CONFIRMED' && Number(adjusted?.version) === manual.version + 2 && closeNumber(adjusted?.adjustedWeightValue, adjustedValue) - && adjusted?.adjustedWeightUnit === 'LB' && closeNumber(adjusted?.finalWeightKg, expectedKg), - `adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, finalKg=${adjusted?.finalWeightKg ?? 'missing'}, expectedKg=${expectedKg}`, + && adjusted?.adjustedWeightUnit === 'LB' + && closeNumber(adjusted?.grossWeightKg, expectedGrossKg, 0.0011) + && closeNumber(adjusted?.netWeightKg, expectedNetKg, 0.0011), + `adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, grossKg=${adjusted?.grossWeightKg ?? 'missing'}, netKg=${adjusted?.netWeightKg ?? 'missing'}, expectedNetKg=${expectedNetKg}`, NEW_CONTRACT_OPTIONS) manual.version = Number(adjusted?.version ?? manual.version) const deletedManual = await deleteResult(context, target, manual, '删除本轮人工改重图片及结果') - if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWeight) + if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWorkspaceWeight) } finally { for (const item of created.filter((value) => !value.deleted)) { await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS) @@ -433,11 +531,10 @@ export async function run(context) { const cleanup = await loadWorkspace(context, target, '最终清理后回查重量工作区') const createdIds = new Set(created.map((item) => item.resultId)) const leftovers = (cleanup.workspace?.weightResults || []).filter((row) => createdIds.has(id(row.id))) - const restored = leftovers.length === 0 && (baselineWeight == null - ? cleanup.workspace?.actualGrossWeightKg == null - : closeNumber(cleanup.workspace?.actualGrossWeightKg, baselineWeight)) - context.assert(MODULE, '仅清理本轮结果且订单总毛重恢复运行前基线', cleanup.response, - restored, `leftovers=${leftovers.length}, baseline=${baselineWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`, + const restored = leftovers.length === 0 + && workspaceWeightMatchesBaseline(cleanup.workspace, baselineWorkspaceWeight) + context.assert(MODULE, '仅清理本轮结果且工作区重量恢复运行前基线', cleanup.response, + restored, `leftovers=${leftovers.length}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) context.createdResources.inboundWeightOcr.cleaned = restored } diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index a80b76097..9d12972a5 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') +const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8') test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) @@ -11,10 +12,22 @@ test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) }) +test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { + for (const source of [sql, iamSeed]) { + assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/) + assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/) + assert.match(source, /status = 'APPROVED'/) + } +}) + test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { for (const marker of [ "'TP-001'", "'TP-002'", "'A1 普通库位'", "'A0 共享溢出位'", - "'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'", + "'CAR-001'", "'CARRIER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'", ]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`) assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/) + + const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0] + assert.ok(palletSeed, 'missing pallet initialization seed') + assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) diff --git a/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs new file mode 100644 index 000000000..1dd225cb0 --- /dev/null +++ b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8') +const loading = read('phase3-loading.mjs') +const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') +const outbox = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java') +const event = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') +const admin = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue') + +test('fixture locks customer and latest internal note sources without cargoRemark leakage', () => { + assert.match(loading, /assignedItem\?\.customerRemark === orderRemarks\.customerRemark/) + assert.match(loading, /assignedItem\?\.internalRemark === orderRemarks\.internalRemark/) + assert.match(loading, /assignedItem\?\.internalRemark !== orderRemarks\.cargoRemark/) + assert.match(service, /item\.setCustomerRemark\(cargo\.customerRemark\(\)\)/) + assert.match(service, /item\.setInternalRemark\(cargo\.latestInternalNote\(\)\)/) +}) + +test('warehouse remark PATCH covers save clear stale version and source preservation', () => { + assert.match(openapi, /items\/\{itemId\}\/warehouse-remark/) + assert.match(openapi, /expectedPlanVersion/) + assert.match(openapi, /expectedItemVersion/) + assert.match(openapi, /null\/空字符串均表示清空/) + assert.match(openapi, /COMPLETED、计划\/明细版本冲突返回 A0409 且事务零写入/) + assert.match(openapi, /数量调整或动态增量不会覆盖已有 warehouseRemark/) + assert.match(loading, /EDITING 装柜明细保存仓库备注/) + assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/) + assert.match(loading, /仓库备注精确重放不重复递增版本或历史事实/) + assert.match(loading, /仓库备注旧版本 A0409 且零写入/) + assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/) + assert.match(loading, /EDITING 装柜明细空白仓库备注归一化为 null/) + assert.match(loading, /恢复真实仓库备注供装柜完成事件下发/) + assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) +}) + +test('completed event V2 carries all three remarks with stable event payload', () => { + assert.match(outbox, /FirstLegLoadingPlanCompletedV2/) + for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field)) + assert.match(outbox, /row\.getCustomerRemark\(\)/) + assert.match(outbox, /row\.getInternalRemark\(\)/) + assert.match(outbox, /row\.getWarehouseRemark\(\)/) + assert.match(outbox, /HANDOVER_VERSION = 2L/) + assert.match(outbox, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/) + assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/) + assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 2/) +}) + +test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => { + assert.match(admin, /备注(客户)/) + assert.match(admin, /备注(内部)/) + assert.match(admin, /备注(仓库)/) + assert.match(admin, /编辑备注/) + assert.match(admin, /COMPLETED/) + assert.doesNotMatch(admin, /v-model="[^\"]*customerRemark|v-model="[^\"]*internalRemark/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 130f30ba6..2feac4e9e 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -16,8 +16,28 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.match(source, /prepareInbound/) assert.match(source, /\.\/orders\.mjs/) assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.doesNotMatch(source, /context\.request\('POST', CONTAINERS/) + assert.match(source, /context\.request\('POST', CONTAINERS/) assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/) + const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`') + const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`') + assert.ok(transportIndex >= 0 && pushIndex > transportIndex) + const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex) + const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex) + const canonicalTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', unconfirmedTaskIndex + 1) + assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex + && canonicalTaskIndex > confirmIndex) + assert.match(source, /计划确认前不生成 PDA 任务/) + assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /confirmationStatus === 'CONFIRMED'/) + assert.match(source, /readiness\?\.readyForPda === true/) + assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/) + assert.match(source, /计划确认同键同参精确重放/) + assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) + assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/) + assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/) + assert.match(source, /bookingAgentId: null, bookingAgentName: null/) + assert.match(source, /billOfLadingNo: confirmed\?\.oceanBooking\?\.billOfLadingNo/) const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete')) assert.doesNotMatch(batch, /containerNo:/) }) @@ -30,6 +50,11 @@ test('does not mutate historical fixtures or remove retained records', () => { assert.match(source, /context\.marker = originalMarker/) }) +test('fails closed when invoked outside the manual matrix wrapper', () => { + assert.match(source, /if \(!matrix\?\.pages\)/) + assert.match(source, /missing=manualStateMatrix\.pages/) +}) + test('covers simplified clearance and customs actions through public multipart upload and complete APIs', () => { assert.match(source, /new FormData\(\)/) assert.match(source, /form\.append\('file'/) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index d9f3ec0ba..96eaed373 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -97,32 +97,54 @@ async function readInbound(context, fixture) { async function createConfirmedContainer(context, source, label) { const value = suffix(context, label) + const containerNo = `MAN-${value}`.slice(0, 64) const summary = source.detail.summary const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', - channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId, - warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, - bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', + channelCode: summary.channelCode, warehouseId: source.warehouseId, + billOfLadingNo: `BL-${value}`.slice(0, 128), + shippingCompanyId: null, shippingCompanyName: null, + bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX', + vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`, + placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + remark: `${context.marker}-${label}`, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], - containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, - maxWeightKg: 28000, quantity: 1 }], + containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, + maxWeightKg: 28000 }], } + const simulationResponse = await context.request('POST', CONTAINERS, { + channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC', + capacityCbm: 76, maxWeightKg: 28000, + }) + const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) + const containerId = id(simulation?.id) + if (!containerId) throw new Error(`${label} 未生成内部模拟柜`) const createdResponse = await post(context, '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, `${label}-booking-create`) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) const bookingId = id(created?.oceanBooking?.id) - const submittedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/submit`, - { expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker }, `${label}-booking-submit`) - const submitted = context.apiSuccess(MODULE, `${label} 提交订舱`, submittedResponse, '', OPTIONS) - const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, - { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`) - const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS) - const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId) - if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`) + const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, { + expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, + }, `${label}-booking-release`) + const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS) + context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, + confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0), + `status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS) + const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`) + const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, + expectedVersion: Number(simulation.version), + }) + const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS) + if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) { + throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`) + } + context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS) return containerId } @@ -145,10 +167,6 @@ async function createPlanTask(context, label) { expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`), }) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) - const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, - { expectedVersion: Number(plan.version) }, `${label}-push`) - plan = context.apiSuccess(MODULE, `${label} 推送预装`, pushResponse, '', OPTIONS) - const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS) const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64) @@ -156,11 +174,65 @@ async function createPlanTask(context, label) { containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version), reason: `${context.marker} 人工状态样本`, }) - context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS) + const transported = context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS) + if (transported?.containerNo !== containerNo || id(transported?.id) !== containerId + || transported?.cabinetNo !== container?.cabinetNo) { + throw new Error(`${label} 真实集装箱号未绑定到稳定柜次`) + } + const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, + { expectedVersion: Number(plan.version) }, `${label}-push`) + plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS) + const planId = id(plan?.id) + const unconfirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) + context.addStep(MODULE, `${label} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse, + unconfirmedTaskResponse.http === 200 && String(unconfirmedTaskResponse?.body?.code) === 'A0404', + `HTTP=${unconfirmedTaskResponse.http}, code=${unconfirmedTaskResponse?.body?.code ?? 'missing'}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS) + + const confirmableResponse = await context.request('GET', + `${LOADING}/loading-plans/${planId}?warehouseId=${source.warehouseId}`) + const confirmable = context.apiSuccess(MODULE, `${label} 读取计划级确认即时版本`, confirmableResponse, '', OPTIONS) + const confirmableVersion = Number(confirmable?.summary?.version) + context.assert(MODULE, `${label} 整计划允许确认且 PDA 尚未就绪`, confirmableResponse, + Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING' + && confirmable?.summary?.readiness?.canConfirm === true + && confirmable?.summary?.readiness?.readyForPda === false + && confirmable?.summary?.readiness?.pdaTaskId == null, + `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) + if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) { + throw new Error(`${label} 计划尚未满足整计划确认门禁`) + } + + const confirmPath = `${LOADING}/loading-plans/${planId}/confirm` + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmHeaders = Object.freeze({ + 'Idempotency-Key': operationId(context, `${label}-plan-confirm`), + }) + const confirmResponse = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders) + const confirmed = context.apiSuccess(MODULE, `${label} 计划级确认并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS) + const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId) + context.assert(MODULE, `${label} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse, + id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId + && confirmed?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId) + && Number(confirmed?.version) > confirmableVersion, + `confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS) + if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') { + throw new Error(`${label} 计划确认未生成唯一 PDA 任务`) + } + + const confirmReplay = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders) + context.assert(MODULE, `${label} 计划确认同键同参精确重放`, confirmReplay, + confirmReplay.http === 200 && String(confirmReplay?.body?.code) === '200' + && id(confirmReplay?.body?.data?.readiness?.pdaTaskId) === confirmedTaskId + && Number(confirmReplay?.body?.data?.version) === Number(confirmed.version), + `HTTP=${confirmReplay.http}, code=${confirmReplay?.body?.code ?? 'missing'}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS) + const taskResponse = await context.request('GET', - `${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`) + `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS) - if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`) + if (id(task?.id) !== confirmedTaskId || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成或身份漂移`) return { source, containerId, containerNo, plan, task } } @@ -294,6 +366,11 @@ async function completeSimpleDocuments(context, chain, matrix) { } export async function run(context, matrix = context.createdResources.manualStateMatrix) { + if (!matrix?.pages) { + context.addStep(MODULE, '人工装柜状态模块由人工矩阵统一编排', null, false, + 'missing=manualStateMatrix.pages;请通过 manual-state-matrix 入口执行', OPTIONS) + return + } matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, requiredActions: ['clearance'], fixtures: {}, actions: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 4633d86a8..37da55c15 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') @@ -11,14 +13,94 @@ test('default full acceptance includes the retained manual state matrix after li assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'")) }) +test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => { + const expected = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'ocean-bookings', + 'phase3-customs', + 'phase3-loading-finalize', + 'manual-state-matrix', + ] + assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) + assert.deepEqual(parseArguments([ + '--modules', 'order-inbound-status-fixtures,manual-state-matrix', + ]).modules, expected) +}) + +test('focused splitting and manual groups resolve producers before consumers without duplicate internal runs', () => { + const expected = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-order-splitting', + 'ocean-bookings', + 'phase3-customs', + 'phase3-loading-finalize', + 'manual-state-matrix', + ] + assert.deepEqual(normalizeModuleOrder([ + 'phase3-order-splitting', 'manual-state-loading-pda', 'manual-state-matrix', + ]), expected) +}) + test('matrix delegates bounded groups and asserts every declared status has a retained id', () => { for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']) assert.match(source, new RegExp(group)) assert.doesNotMatch(source, /manual-state-customs-dispatch/) assert.match(source, /createdResources\.manualStateMatrix/) + assert.match(source, /supportedStatuses/) assert.match(source, /requiredStatuses/) assert.match(source, /fixtures\?\.\[status\]\?\.id/) assert.match(source, /requiredActions/) assert.match(source, /actions\?\.\[action\]\?\.documentId/) assert.match(source, /actions\?\.\[action\]\?\.completedAt/) }) + +test('fixture-unavailable statuses stay supported without becoming hard requirements', () => { + const matrix = { pages: { + inbounds: { requiredStatuses: ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { + WAIT_MEASURE: { id: '1' }, + } }, + unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: { + WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.inbounds.supportedStatuses, + ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) + assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED']) + assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') + assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') + assert.deepEqual(missingRequiredStateFixtures(matrix), []) +}) + +test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => { + const matrix = { pages: { + unifiedOrders: { supportedStatuses: ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS'], requiredStatuses: [], fixtures: { + WAIT_SPLIT: { id: '1' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS']) + assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:UNKNOWN_FUTURE_STATUS']) +}) + +test('LOADING and LOADED fixtures are resolved through inbound and unified-order details', () => { + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3ContainerLoading', 'LOADING'\)/) + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3Loading', 'LOADED'\)/) + assert.match(source, /\/api\/platform\/inbounds\/\$\{encodeURIComponent\(inboundId\)\}/) + assert.match(source, /id\(inbound\?\.summary\?\.id\) === inboundId/) + assert.match(source, /inbound\?\.summary\?\.processStatus === expectedStatus/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) + assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/) + assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/) + assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/) + assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 27bd9c67d..2bf5d0ff5 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -7,7 +7,100 @@ const GROUPS = [ 'manual-state-ocean-trucking', ] +const FIXTURE_UNAVAILABLE = { + inbounds: { + WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING', + FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路', + INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', + VOIDED: '当前 Controller/Service 没有作废入库单的公开 API', + }, + unifiedOrders: { + DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API', + }, +} + function rows(value) { return Array.isArray(value) ? value : [] } +function id(value) { return value == null ? '' : String(value) } + +export function normalizeStateRequirements(matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + const supportedStatuses = [...new Set([ + ...rows(value?.supportedStatuses), ...rows(value?.requiredStatuses), + ])] + const unavailable = FIXTURE_UNAVAILABLE[page] || {} + value.supportedStatuses = supportedStatuses + value.requiredStatuses = supportedStatuses.filter((status) => !unavailable[status]) + if (Object.keys(unavailable).length > 0) { + value.coverageGaps = Object.fromEntries(Object.entries(unavailable).map(([status, reason]) => [status, { + category: 'fixture-unavailable', reason, + }])) + } + } + return matrix +} + +export function missingRequiredStateFixtures(matrix) { + return Object.entries(matrix?.pages || {}).flatMap(([page, value]) => rows(value?.requiredStatuses) + .filter((status) => !value?.fixtures?.[status]?.id) + .map((status) => `${page}:${status}`)) +} + +function registerFixtureUnavailableGaps(context, matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + for (const [status, gap] of Object.entries(value?.coverageGaps || {})) { + if (value?.fixtures?.[status]?.id) continue + if (rows(context.coverageGaps).some((item) => item?.category === gap.category + && String(item?.name || '').includes(status))) continue + context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason, + gap.category, { marker: context.marker, page, status }) + } + } +} + +function clearResolvedProvisionalGap(context, status) { + if (!Array.isArray(context.coverageGaps)) return + for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) { + const gap = context.coverageGaps[index] + if (gap?.module === 'order-inbound-status-fixtures' + && gap?.scenario === 'manual-order-inbound-status-matrix' + && gap?.category === 'fixture-unavailable' + && gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1) + } +} + +async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) { + const resource = context.createdResources?.[resourceName] + const inboundId = id(resource?.inboundId) + const tenantId = id(resource?.tenantId) + context.assert(MODULE, `${expectedStatus} 专项资源已生成`, null, + Boolean(inboundId && tenantId), + `resource=${resourceName}, inboundId=${inboundId || 'missing'}, tenantId=${tenantId || 'missing'}`, OPTIONS) + if (!inboundId || !tenantId) return + + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`) + const inbound = context.apiSuccess(MODULE, `读取 ${expectedStatus} 专项入库详情`, inboundResponse, '', OPTIONS) + const orderId = id(inbound?.summary?.orderId) + context.assert(MODULE, `${expectedStatus} 入库详情身份与状态正确`, inboundResponse, + id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId + && Boolean(orderId) && inbound?.summary?.processStatus === expectedStatus, + `inboundId=${id(inbound?.summary?.id) || 'missing'}, orderId=${orderId || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, OPTIONS) + if (!orderId || inbound?.summary?.processStatus !== expectedStatus) return + + const unifiedResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`) + const unified = context.apiSuccess(MODULE, `读取 ${expectedStatus} 统一订单详情`, unifiedResponse, '', OPTIONS) + const valid = id(unified?.summary?.orderId) === orderId && unified?.summary?.mainStatus === expectedStatus + context.assert(MODULE, `${expectedStatus} 统一订单详情可打开`, unifiedResponse, valid, + `orderId=${id(unified?.summary?.orderId) || 'missing'}, mainStatus=${unified?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (!valid) return + + matrix.pages.unifiedOrders.fixtures[expectedStatus] = { + id: orderId, status: expectedStatus, inboundId, + detailPath: `/orders/${orderId}`, + } + clearResolvedProvisionalGap(context, expectedStatus) +} export async function run(context) { const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } @@ -19,10 +112,13 @@ export async function run(context) { await extension.run(context, matrix) } + await attachUnifiedOrderFixture(context, matrix, 'phase3ContainerLoading', 'LOADING') + await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED') + normalizeStateRequirements(matrix) + registerFixtureUnavailableGaps(context, matrix) + const pages = Object.entries(matrix.pages) - const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) - .filter((status) => !value?.fixtures?.[status]?.id) - .map((status) => `${page}:${status}`)) + const missing = missingRequiredStateFixtures(matrix) const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions) .filter((action) => !value?.actions?.[action]?.id || !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index bd1ce3870..1caffde46 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -1,15 +1,21 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' - const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8') -test('retains every ocean booking and truck appointment list status through real APIs', () => { - for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED', - 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', - 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) - assert.match(source, /change-request/) - assert.match(source, /\/reschedule\?plannedLoadingAt=/) +test('manual matrix retains only DRAFT and RELEASED ocean booking states', () => { + assert.match(source, /const OCEAN_LIST_STATUSES = \['DRAFT', 'RELEASED'\]/) + assert.match(source, /containerRequirements:/) + assert.doesNotMatch(source, /change-request|\/cancel/) + assert.doesNotMatch(source, /realContainerBookingId|containerNo:\s*`MAN-/) + assert.match(source, /status === 'DRAFT'/) + assert.match(source, /status === 'RELEASED'/) +}) + +test('truck matrix remains independent while retaining its own lifecycle', () => { + for (const status of ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', + 'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) { + assert.match(source, new RegExp(`['\"]${status}['\"]`)) + } assert.match(source, /page=1&size=100&status=/) - assert.doesNotMatch(source, /DELETE/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 3c5328b1e..57b62fb3d 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -1,8 +1,9 @@ const MODULE = 'manual-state-ocean-trucking' const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true } -const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings' +const OCEAN_BASE = '/api/platform/v1/ocean-bookings' const TRUCK_BASE = '/api/platform/v1/truck-appointments' -const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED'] +const OCEAN_LIST_STATUSES = ['DRAFT', 'RELEASED'] +const OCEAN_STATUSES = [...OCEAN_LIST_STATUSES] const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED'] @@ -17,30 +18,39 @@ async function post(context, path, body, operation) { } function oceanPayload(context, template, index) { - const value = suffix(context, index); const source = template.oceanBooking + const source = template.oceanBooking return { expectedVersion: 0, data: { customerId: source.customerId, customerName: source.customerName, - channelCode: source.channelCode, channelName: source.channelName, - warehouseId: source.warehouseId, warehouseName: source.warehouseName, - billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId, - shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, - bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`, + channelCode: source.channelCode, + warehouseId: source.warehouseId, + billOfLadingNo: `BL-${suffix(context, index)}`, vesselName: `人工轮-${suffix(context, index)}`, + voyageNo: `V${suffix(context, index)}`, + shippingCompanyId: source.shippingCompanyId, + shippingCompanyName: source.shippingCompanyId ? source.shippingCompanyName : null, + bookingAgentId: source.bookingAgentId, + bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, - estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00', + estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', remark: `${context.marker} 人工列表状态 ${index}`, parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => ({ partyType, partyName, address, contactName, contactPhone })), - containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => - ({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })), + containerRequirements: template.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => + ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })), } } } async function createOcean(context, template, index) { const response = await post(context, OCEAN_BASE, oceanPayload(context, template, index), `ocean-create-${index}`) const value = context.apiSuccess(MODULE, `创建订舱状态样本 ${index}`, response, '', OPTIONS) - if (!value?.oceanBooking?.id) throw new Error(`创建订舱样本 ${index} 失败`) + context.assert(MODULE, `订舱样本 ${index} 创建为 DRAFT 且无真实柜`, response, + Boolean(value?.oceanBooking?.id) && value?.oceanBooking?.status === 'DRAFT' + && (!value?.containers || value.containers.length === 0), + `id=${id(value?.oceanBooking?.id) || 'missing'}, status=${value?.oceanBooking?.status || 'missing'}`, OPTIONS) + if (!value?.oceanBooking?.id || value?.oceanBooking?.status !== 'DRAFT') { + throw new Error(`创建订舱样本 ${index} 未进入 DRAFT`) + } return value } @@ -55,27 +65,17 @@ async function oceanAction(context, value, endpoint, body = {}) { async function buildOcean(context, template, status, index) { let value = await createOcean(context, template, index) if (status === 'DRAFT') return value - value = await oceanAction(context, value, 'submit') - if (status === 'SUBMITTED') return value - if (status === 'FAILED') return oceanAction(context, value, 'fail') - if (status === 'CANCELLED') return oceanAction(context, value, 'cancel') - value = await oceanAction(context, value, 'confirm') - if (status === 'CONFIRMED') return value - if (status === 'CHANGE_PENDING') return oceanAction(context, value, 'change-request', { - vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`, - estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) - if (status === 'RELEASED') return oceanAction(context, value, 'release', { - billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`, - voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00', - estimatedArrivalAt: '2026-10-16T08:00:00' }) + if (status === 'RELEASED') return oceanAction(context, value, 'release') throw new Error(`unsupported ocean status ${status}`) } -function truckPayload(context, oceanBookingId, index) { +function truckPayload(context, oceanBooking, index) { const value = suffix(context, index) - return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null, - warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户', - carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004', + return { outboundOrderId: null, oceanBookingId: oceanBooking.id, + containerBookingId: null, loadingPlanId: null, + warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId, + customerName: oceanBooking.customerName, + carrierId: null, carrierName: null, fleetContact: '人工验收联系人', fleetPhone: '13800000004', driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000', @@ -84,8 +84,8 @@ function truckPayload(context, oceanBookingId, index) { receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } } -async function createTruck(context, oceanBookingId, index) { - const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index)) +async function createTruck(context, oceanBooking, index) { + const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) return value @@ -99,8 +99,8 @@ async function truckAction(context, value, endpoint) { return next } -async function buildTruck(context, oceanBookingId, status, index) { - let value = await createTruck(context, oceanBookingId, index) +async function buildTruck(context, oceanBooking, status, index) { + let value = await createTruck(context, oceanBooking, index) if (status === 'DRAFT') return value if (status === 'RESCHEDULED') { const response = await post(context, @@ -133,22 +133,33 @@ export async function run(context, matrix = context.createdResources.manualState const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`) const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS) if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) { - throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求') + throw new Error('订舱模板缺少渠道、仓库、参与方或柜型需求') } - matrix.pages.oceanBookings = { requiredStatuses: OCEAN_STATUSES, fixtures: {} } + matrix.pages.oceanBookings = { requiredStatuses: OCEAN_LIST_STATUSES, fixtures: {} } for (const [index, status] of OCEAN_STATUSES.entries()) { const value = await buildOcean(context, template, status, index) const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status - matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` } context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status, `id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS) + if (!resourceId || actual !== status) continue + const fixtureValue = { + id: resourceId, status: actual, + customerId: id(value?.oceanBooking?.customerId), customerName: value?.oceanBooking?.customerName, + warehouseId: id(value?.oceanBooking?.warehouseId), + } + matrix.pages.oceanBookings.fixtures[status] = { + ...fixtureValue, detailPath: `/ocean-bookings/${resourceId}`, + } await verify(context, OCEAN_BASE, status, resourceId) } - const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id + const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED + if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) { + throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属') + } matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} } for (const [index, status] of TRUCK_STATUSES.entries()) { - const value = await buildTruck(context, releasedOceanId, status, index) + const value = await buildTruck(context, releasedOcean, status, index) const resourceId = id(value?.id) matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` } context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status, diff --git a/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs b/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs new file mode 100644 index 000000000..9b21e02ca --- /dev/null +++ b/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./master-data.mjs', import.meta.url), 'utf8') + +test('master-data uses only current partner types for API and import fixtures', () => { + assert.match(source, /partnerType: 'CARRIER', types: \['CARRIER'\]/) + assert.match(source, /types: \['CARRIER'\]/) + assert.match(source, /`IMP\$\{suffix\}`, `导入船公司-\$\{suffix\}`, 'CARRIER'/) + assert.doesNotMatch(source, /partnerType: 'SUPPLIER'|types: \['SUPPLIER'|SHIPPING_COMPANY/) +}) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index 85a505861..c8c8160fe 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -158,7 +158,7 @@ export async function run(context) { expressPage?.records?.some((item) => id(item.id) === expressId && item.status === 'INACTIVE'), `records=${expressPage?.records?.length ?? 'n/a'}`) const partnerPayload = { - partnerCode: `PT${suffix}`, partnerName: `Admin验收往来单位-${suffix}`, partnerType: 'SUPPLIER', types: ['SUPPLIER'], + partnerCode: `PT${suffix}`, partnerName: `Admin验收船公司-${suffix}`, partnerType: 'CARRIER', types: ['CARRIER'], contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`, countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址', postalCode: '518000', status: 'ACTIVE', remark: context.marker, @@ -171,8 +171,8 @@ export async function run(context) { const partnerDetailResponse = await context.request('GET', partnerPath) const partner = context.apiSuccess(module, '查询往来单位详情', partnerDetailResponse) context.assert(module, '往来单位详情正确', partnerDetailResponse, - id(partner?.id) === partnerId && partner?.types?.includes('SUPPLIER'), `partnerId=${partnerId}`) - const updatePartnerResponse = await context.request('PUT', partnerPath, { ...partnerPayload, partnerName: `${partnerPayload.partnerName}-已编辑`, types: ['SUPPLIER', 'CARRIER'], remark: `${context.marker}-EDITED` }) + id(partner?.id) === partnerId && partner?.types?.includes('CARRIER'), `partnerId=${partnerId}`) + const updatePartnerResponse = await context.request('PUT', partnerPath, { ...partnerPayload, partnerName: `${partnerPayload.partnerName}-已编辑`, types: ['CARRIER'], remark: `${context.marker}-EDITED` }) context.apiSuccess(module, '编辑往来单位', updatePartnerResponse) const disablePartnerResponse = await context.request('PATCH', `/api/platform/v1/partners/${partnerId}/status?tenantId=0`, { status: 'INACTIVE' }) context.apiSuccess(module, '停用往来单位', disablePartnerResponse) @@ -190,7 +190,7 @@ export async function run(context) { const importFixtures = { 仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' }, 快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' }, - 往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入单位-${suffix}`, 'SUPPLIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' }, + 往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' }, } for (const [name, path] of templates) { const response = await context.requestBinary('GET', path) diff --git a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs new file mode 100644 index 000000000..a85575731 --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import test from 'node:test' + +const adminPageUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url) +const adminTestUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url) +const adminApiUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', import.meta.url) +const openapiUrl = new URL('../../firstleg/src/main/resources/openapi/', import.meta.url) + +function source(url) { return readFileSync(url, 'utf8') } + +test('Admin ocean booking selects a warehouse-scoped logistics channel instead of accepting free text', () => { + const page = source(adminPageUrl) + const api = source(adminApiUrl) + const pageTest = source(adminTestUrl) + + assert.doesNotMatch(page, /]*v-model="form\.data\.channelCode"/) + assert.doesNotMatch(page, /]*v-model="form\.data\.channelName"/) + assert.match(page, /]*label="物流渠道"[^>]*>[\s\S]*?]*:model-value="form\.data\.channelCode"[^>]*@update:model-value="selectChannel\(/) + assert.match(`${page}\n${api}`, /originWarehouseId/) + assert.match(api, /basePath\s*=\s*['"]\/platform\/v1\/ocean-bookings['"][\s\S]*\$\{basePath\}\/channels/) + assert.doesNotMatch(api, /price-books[\s\S]*for \(const version of versions\)/) + assert.match(page, /shippingCompanyId/) + assert.match(page, /channelCode/) + assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/) + assert.match(pageTest, /listWarehouseLogisticsChannels|channelCode/) +}) + +test('backend OpenAPI freezes a platform channel query scoped by originWarehouseId', () => { + const documents = readdirSync(openapiUrl) + .filter((name) => name.endsWith('.yaml')) + .map((name) => source(new URL(name, openapiUrl))) + const platformChannelContract = documents.find((value) => + /\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value) + && /current ACTIVE price book/.test(value)) + assert.ok(platformChannelContract, + 'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI') +}) + +test('released requirement association locks the selected channel onto the simulation', () => { + const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url)) + assert.match(fixture, /ocean-booking-link/) + assert.match(fixture, /订舱放舱为 RELEASED 且不预生成真实货柜/) + assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) + assert.match(fixture, /containerDetail\?\.channelCode === channelCode/) + assert.match(fixture, /selectPlatformChannelIsolationPair/) + assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/) +}) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index a4eff3ad3..1eeb5857a 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -2,57 +2,75 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' -test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => { +const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') +const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' } + +test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => { const steps = [] - const context = { - marker: 'ADMIN-RUN-1', - createdResources: { orchestrationFixtures: { phase3Container: { - marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31', - } } }, - request: async () => ({ body: { code: '200', data: { summary: { - tenantId: 21, customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', - warehouseId: 41, warehouseName: '深圳仓', - } } } }), - addStep: (...args) => steps.push(args), - } - const master = await resolveOceanBookingMaster(context, 'test') - assert.deepEqual(master, { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', - channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' }) + const context = { marker: 'ADMIN-RUN-1', createdResources: { orchestrationFixtures: { phase3Container: { marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31' } } }, + request: async () => ({ body: { code: '200', data: { summary: { tenantId: 21, customerName: master.customerName, channelCode: master.channelCode, channelName: master.channelName, warehouseId: 41, warehouseName: master.warehouseName } } } }), + addStep: (...args) => steps.push(args) } + assert.deepEqual(await resolveOceanBookingMaster(context, 'test'), master) assert.equal(steps.length, 0) + const blocked = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) } + assert.equal(await resolveOceanBookingMaster(blocked, 'test'), null) + assert.match(steps.at(-1)[4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/) }) -test('ocean booking fixture blocks creation instead of inventing missing master data', async () => { - const steps = [] - const context = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) } - assert.equal(await resolveOceanBookingMaster(context, 'test'), null) - assert.equal(steps.length, 1) - assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/) +test('shared DRAFT uses requirements and never registers a real container number', () => { + const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master) + assert.equal(draft.channelName, undefined) + assert.equal(draft.warehouseName, undefined) + assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }]) + assert.equal(Object.hasOwn(draft, 'containers'), false) + assert.equal(JSON.stringify(draft).includes('containerNo'), false) + assert.equal(draft.serviceTerm, 'CY-CY') + assert.equal(draft.freightTerm, 'NINGBO') + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) }) -test('shared booking draft carries all required DTO fields without release transport data', () => { - const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', { - customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', - warehouseId: '41', warehouseName: '深圳仓', - }) - for (const key of ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']) { - assert.ok(draft[key], `${key} is required`) - } - assert.equal(draft.containerRequirements[0].capacityCbm, 68) - assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) - for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { - assert.equal(draft[key], undefined, `${key} belongs to release, not draft`) - } +test('shared DRAFT permits an omitted customer without inventing placeholders', () => { + const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', master) + assert.equal(Object.hasOwn(draft, 'customerId'), false) + assert.equal(Object.hasOwn(draft, 'customerName'), false) + assert.equal(draft.containerRequirements[0].quantity, 1) }) -test('all independent ocean booking producers use the shared master-data contract', () => { - for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) { - const source = readFileSync(new URL(name, import.meta.url), 'utf8') - assert.match(source, /resolveOceanBookingMaster\(context, MODULE\)/, name) - assert.match(source, /oceanBookingDraft\(context, suffix, master/, name) +test('explicit requirement rows preserve quantity and fixed catalog facts', () => { + const requirements = [ + { containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }, + { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + ] + const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements }) + assert.deepEqual(draft.containerRequirements, requirements) + assert.equal(JSON.stringify(draft).includes('containerNo'), false) +}) + +test('supplier names are emitted only with formal supplier ids', () => { + const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', master, + { shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' }) + assert.equal(draft.shippingCompanyName, null) + assert.equal(draft.bookingAgentName, null) +}) + +test('platform channel producer uses the selected origin warehouse and publishes current-marker resources', async () => { + const calls = [] + const context = { marker: 'ADMIN-RUN-CHANNELS', createdResources: {}, request: async (method, path) => { + calls.push({ method, path }); return { body: { code: '200', data: [{ channelCode: 'SEA-A', channelName: '海运 A' }] } } + }, apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, _name, _response, condition) => assert.equal(condition, true) } + const catalog = await publishPlatformLogisticsChannels(context, '41', 'test') + assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }]) + assert.equal(catalog.marker, context.marker) + assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog) +}) + +test('all production booking fixtures use DRAFT release and the shared requirements contract', () => { + for (const name of ['ocean-bookings.mjs', 'phase3-loading.mjs', 'phase3-container-loading.mjs', 'phase3-order-splitting.mjs', 'manual-state-loading-pda.mjs', 'manual-state-ocean-trucking.mjs']) { + const source = read(name) + assert.doesNotMatch(source, /realContainerBookingId|ocean-bookings\/[^`'"\s]+\/(?:submit|confirm)/, name) + assert.match(source, /containerRequirements/, name) } - const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8') - for (const snippet of ['channelCode: channelCode', 'channelName', 'warehouseId', 'warehouseName', - 'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 38439d421..13b80411c 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -1,4 +1,25 @@ function text(value) { return value == null ? '' : String(value).trim() } +const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels' + +export async function publishPlatformLogisticsChannels(context, originWarehouseId, module) { + const warehouseId = text(originWarehouseId) + const sourcePath = `${PLATFORM_CHANNELS}?originWarehouseId=${encodeURIComponent(warehouseId)}` + const response = await context.request('GET', sourcePath) + const data = context.apiSuccess(module, '按起运仓查询平台正式物流渠道', response, + `originWarehouseId=${warehouseId}`, { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + const channels = (Array.isArray(data) ? data : []).flatMap((value) => { + const channelCode = text(value?.channelCode) + const channelName = text(value?.channelName) + return channelCode && channelName ? [{ channelCode, channelName }] : [] + }) + const resource = { marker: context.marker, originWarehouseId: warehouseId, sourcePath, channels } + context.createdResources.platformLogisticsChannels = resource + context.assert(module, '平台渠道目录归属本轮起运仓且不含空渠道', response, + Boolean(warehouseId) && channels.length > 0, + `originWarehouseId=${warehouseId}, channels=${channels.map((value) => value.channelCode).join('|') || 'none'}`, + { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + return resource +} export async function resolveOceanBookingMaster(context, module) { const resources = context.createdResources || {} @@ -32,18 +53,34 @@ export async function resolveOceanBookingMaster(context, module) { } export function oceanBookingDraft(context, suffix, master, labels = {}) { + const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master + const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' + const containerRequirements = labels.containerRequirements || [{ + containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000, + }] return { - ...master, - shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`, - bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`, - vesselName: labels.vesselName, voyageNo: labels.voyageNo, + ...writableMaster, + billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, + shippingCompanyId: labels.shippingCompanyId ?? null, + shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null, + bookingAgentId: labels.bookingAgentId ?? null, + bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null, + vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', - estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt, + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00', + estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], - containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], + containerRequirements, } } + +export function oceanBookingDraftWithoutCustomer(context, suffix, master, labels = {}) { + const draft = oceanBookingDraft(context, suffix, master, labels) + delete draft.customerId + delete draft.customerName + return draft +} diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs new file mode 100644 index 000000000..114d4e677 --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8') +const adminPage = read('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue') +const adminTypes = read('../../../apps/admin-web/src/modules/ocean-bookings/types.ts') +const prototype = read('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html') +const dto = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java') +const openapi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +const fixture = read('ocean-booking-fixture.mjs') +const booking = read('ocean-bookings.mjs') +const loading = read('phase3-container-loading.mjs') +const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + +test('prototype evidence keeps terms and optional customer while current DRAFT stays editable', () => { + assert.match(prototype, /主要运输条款 Service Term \*<\/label>