diff --git a/AGENTS.md b/AGENTS.md index 28b316cf6..35f4819ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ ### 后端任务最小读取集 - 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。 -- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。 +- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。 - 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。 - 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。 @@ -78,6 +78,7 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | +| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | ## 公共模块快速索引 @@ -105,7 +106,7 @@ ## 契约、数据库与验证入口 -- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 +- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 - 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 @@ -140,7 +141,7 @@ - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 -- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 +- 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务 - 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 diff --git a/Jenkinsfile b/Jenkinsfile index 36bbea19e..01e5992d5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -161,7 +161,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址') + string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } @@ -190,9 +190,12 @@ pipeline { String requestedRepository = params.IMAGE_REPOSITORY?.trim() - if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { + if (!requestedRepository) { error('必须填写真实镜像仓库地址') } + if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) { + error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址') + } if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 310979127..a83e45409 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -49,7 +49,7 @@ pipeline { booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') + string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } @@ -101,6 +101,9 @@ pipeline { if (!imageRepository || imageRepository.contains('://')) { error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀') } + if (imageRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) { + error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址') + } if (!imageRepository.contains('/')) { error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') } diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java index 7859c7a44..f63732284 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -20,6 +20,7 @@ public class JwksJwtDecoderAutoConfiguration { JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri, @Value("${baiyue.security.issuer:auth-service}") String issuer, @Value("${baiyue.security.audience:lastleg-api}") String audience) { + // Fetch and cache JWKS on token verification, so startup does not depend on Auth availability. NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) .jwsAlgorithm(SignatureAlgorithm.RS256).build(); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( diff --git a/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java b/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java new file mode 100644 index 000000000..8bf838451 --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java @@ -0,0 +1,94 @@ +package com.baiyue.common.security; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.JwtException; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwksJwtDecoderAutoConfigurationTest { + @Test + void createsDecoderWhenAuthIsUnavailableButRejectsTokenVerification() throws Exception { + int port; + try (var socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder( + "http://127.0.0.1:" + port + "/jwks", "auth-service", "lastleg-api"); + var key = new RSAKeyGenerator(2048).keyID("current").generate(); + String token = token(key, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256); + assertThatThrownBy(() -> decoder.decode(token)).isInstanceOf(JwtException.class); + } + + @Test + void fetchesOnFirstVerificationCachesKeysAndRefreshesForRotation() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").generate(); + var next = new RSAKeyGenerator(2048).keyID("next").generate(); + var published = new AtomicReference<>(new JWKSet(current.toPublicJWK())); + var requests = new AtomicInteger(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/jwks", exchange -> { + requests.incrementAndGet(); + byte[] body = published.get().toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + try { + var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder( + "http://127.0.0.1:" + server.getAddress().getPort() + "/jwks", + "auth-service", "lastleg-api"); + assertThat(requests.get()).isZero(); + String valid = token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256); + assertThat(decoder.decode(valid).getSubject()).isEqualTo("42"); + assertThatCode(() -> decoder.decode(valid)).doesNotThrowAnyException(); + assertThat(requests.get()).isEqualTo(1); + + for (String invalid : new String[]{ + token(current, "other", "lastleg-api", 120, JWSAlgorithm.RS256), + token(current, "auth-service", "other", 120, JWSAlgorithm.RS256), + token(current, "auth-service", "lastleg-api", -120, JWSAlgorithm.RS256), + token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS512)}) { + assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class); + } + + published.set(new JWKSet(next.toPublicJWK())); + assertThat(decoder.decode(token(next, "auth-service", "lastleg-api", 120, + JWSAlgorithm.RS256)).getSubject()).isEqualTo("42"); + assertThat(requests.get()).isEqualTo(2); + } finally { + server.stop(0); + } + } + + private String token(RSAKey key, String issuer, String audience, int ttl, JWSAlgorithm algorithm) + throws Exception { + var jwt = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(key.getKeyID()).build(), + new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience) + .issueTime(Date.from(Instant.now().minusSeconds(300))) + .expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build()); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } +} diff --git a/config/nacos/test/file-service.yml b/config/nacos/test/file-service.yml index 2d1ff9358..48155dcdb 100644 --- a/config/nacos/test/file-service.yml +++ b/config/nacos/test/file-service.yml @@ -123,4 +123,76 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + LAST_MILE_ORDER_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches + required-permission: platform:overseas-warehouse:last-mile-order:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_INVENTORY_BALANCE: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches + required-permission: platform:overseas-warehouse:inventory:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_INVENTORY_LEDGER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches + required-permission: platform:overseas-warehouse:inventory:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_QUOTE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches + required-permission: platform:overseas-master-data:quote:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + MONTHLY_EXCHANGE_RATE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches + required-permission: platform:overseas-master-data:exchange-rate:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + VEHICLE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/VEHICLE_LIST/batches + required-permission: platform:overseas-master-data:vehicle:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + FLEET_QUOTE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches + required-permission: platform:fleet:quote:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + TRIP_OPERATIONS_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches + required-permission: platform:fleet:operations:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index ebf36ab58..dec949876 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -13,7 +13,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 157 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index beb625c27..e9d82747c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6327,7 +6327,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107004,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107005,0,CURRENT_TIMESTAMP(3)); @@ -6383,8 +6383,10 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6755,8 +6757,10 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107121,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107122,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107123,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107130,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107190,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107044,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107200,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107201,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107202,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d85e588b5..1516e2303 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -13,6 +13,7 @@ USE baiyue_lastleg; CREATE TABLE ll_cargo_discard_application ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认完成时间', discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', @@ -68,6 +69,7 @@ CREATE TABLE ll_cargo_discard_item ( product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', discard_packages int NOT NULL COMMENT '申请废弃箱数', + completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组', actual_discard_packages int DEFAULT NULL COMMENT 'PDA确认实际废弃箱数', pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人', @@ -377,7 +379,6 @@ CREATE TABLE ll_dispatch_item ( source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', - PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -436,7 +437,6 @@ CREATE TABLE ll_dispatch_pallet_lock ( 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 '更新时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', - PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -463,12 +463,13 @@ CREATE TABLE ll_dispatch_pda_pick ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', task_id bigint NOT NULL COMMENT 'PDA任务ID', dispatch_item_id bigint NOT NULL COMMENT '派车明细ID', - inventory_position_id bigint NOT NULL COMMENT '库存仓位记录ID', + inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID', packages bigint NOT NULL COMMENT '本次操作箱数', product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', picked_at datetime(3) NOT NULL COMMENT '拣货时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) @@ -508,10 +509,9 @@ CREATE TABLE ll_dispatch_pda_pick_order ( product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), - - + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) @@ -2373,10 +2373,13 @@ CREATE TABLE ll_return_inventory_carton ( merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), - UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), - CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))), - CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED'))) + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')), + source_unit_code VARCHAR(128) NULL COMMENT '来源单元业务码', + location_code VARCHAR(128) NULL COMMENT '退货箱当前库位', + putaway_at DATETIME(3) NULL COMMENT '退货箱上架时间', + source_unit_type VARCHAR(32) NULL COMMENT 'PALLET或BOX实物箱 PARCEL散SKU' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; CREATE TABLE ll_return_inventory_carton_item ( @@ -2389,7 +2392,8 @@ CREATE TABLE ll_return_inventory_carton_item ( PRIMARY KEY (id), UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code), - CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)) + CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)), + packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; CREATE TABLE ll_return_inventory_operation ( @@ -2403,7 +2407,8 @@ CREATE TABLE ll_return_inventory_operation ( updated_at datetime(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key (operator_id,idempotency_key), - KEY idx_ll_return_inventory_operation_result (result_carton_id) + KEY idx_ll_return_inventory_operation_result (result_carton_id), + result_json JSON NULL COMMENT '幂等操作不可变返回快照' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计'; CREATE TABLE ll_return_order ( @@ -2479,7 +2484,14 @@ CREATE TABLE ll_return_receipt ( UNIQUE KEY uk_ll_return_receipt_operator_key (operator_id,idempotency_key), KEY idx_ll_return_receipt_order (return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_bound CHECK ((bound_by in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))), - CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)) + CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)), + unit_type VARCHAR(16) NULL COMMENT '签收类型 PALLET/BOX/PARCEL', + unit_code VARCHAR(128) NULL COMMENT '签收单元业务码', + total_box_count BIGINT NULL COMMENT '签收SKU箱数汇总', + length DECIMAL(12,3) NULL COMMENT '实测尺寸cm', + width DECIMAL(12,3) NULL COMMENT '实测尺寸cm', + height DECIMAL(12,3) NULL COMMENT '实测尺寸cm', + weight DECIMAL(12,3) NULL COMMENT '实测重量kg' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实'; CREATE TABLE ll_return_receipt_batch ( @@ -2508,7 +2520,8 @@ CREATE TABLE ll_return_receipt_item ( PRIMARY KEY (id), KEY idx_ll_return_receipt_item_receipt (receipt_id), KEY idx_ll_return_receipt_item_order_item (return_order_item_id), - CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)) + CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)), + box_count BIGINT NULL COMMENT 'SKU签收箱数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; @@ -3350,6 +3363,36 @@ CREATE TABLE ll_warehouse_transfer_source_order ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源历史订单绑定'; -- 服务 schema:baiyue_lastlag_warehouse +CREATE TABLE ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细', + mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消', + remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no), + UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key), + KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at), + source_items_json JSON NULL COMMENT '精确来源箱和SKU锁定数量', + result_marks_json JSON NULL COMMENT '完成生成或并入的目标箱唛', + request_hash VARCHAR(64) NULL COMMENT '创建载荷摘要', + completion_hash VARCHAR(64) NULL COMMENT '完成载荷摘要', + result_labels_json JSON NULL COMMENT '计划完工标签快照' +) ENGINE=InnoDB COMMENT='退货合箱计划'; + +CREATE TABLE ll_return_packing_trace ( + id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键', + operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号', + sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数', + created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id), + KEY idx_return_packing_trace_order(customer_id,return_no,created_at), + KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code), + KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code), + CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯'; + CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastlag_warehouse; @@ -3540,4 +3583,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables; diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md deleted file mode 100644 index 85e616a0d..000000000 --- a/event-bridge-service/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Event Bridge Service 模块指引 - -> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。 - -## 职责边界 - -- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 -- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。 -- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 -- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 - -## 快速索引 - -| 功能 | 入口 | -|---|---| -| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | -| Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | -| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | -| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | -| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg | -| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | -| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | -| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | -| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | -| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | -| 数据库 | `src/main/resources/db/event-bridge.sql` | -| 测试 | `src/test/java/com/baiyue/eventbridge/` | - -## 可靠投递规则 - -- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。 -- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 -- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 -- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 -- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 -- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 - -## 修改与验证 - -- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。 -- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md deleted file mode 100644 index 1c05c13ce..000000000 --- a/event-bridge-service/docs/development-observability-slo.md +++ /dev/null @@ -1,67 +0,0 @@ -# Event Bridge 开发阶段可观测性与 SLO 合同 - -## 状态与边界 - -本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了 -Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。 - -指标标签只允许 `target_region`、`event_type`、`result`: - -- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。 -- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。 -- `result` 仅为代码中冻结的接收或传输结果枚举。 -- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。 - -## 指标合同 - -| Prometheus 指标 | 含义 | -|---|---| -| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` | -| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` | -| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 | -| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 | -| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 | -| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 | - -`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数, -不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。 - -Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge, -不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有 -`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。 - -## 开发阶段 SLI 与 SLO - -SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT` -不重复进入分母。滚动 30 天目标: - -- 99% 的新接收跨区事件在 60 秒内得到下游确认。 -- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。 - -代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组: - -```promql -sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d])) -/ -sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99 -``` - -```promql -sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d])) -/ -sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999 -``` - -## 告警合同 - -规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同: - -- 新增 DEAD:下一次评估立即告警,不设置持续等待。 - `sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0` -- 最老待处理年龄预警:任一有限标签序列超过 120 秒。 - `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120` -- 最老待处理年龄严重:任一有限标签序列超过 900 秒。 - `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900` -- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。 - -告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md deleted file mode 100644 index 00c2e42c3..000000000 --- a/event-bridge-service/docs/event-trace-propagation.md +++ /dev/null @@ -1,16 +0,0 @@ -# Event Bridge Trace 传播基线 - -- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 -- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 -- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 -- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 -- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 -- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` - 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 - -## 共享契约缺口 - -当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 -无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 -`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 -Payload、信封字段、哈希规则或可靠性状态机。 diff --git a/event-bridge-service/pom.xml b/event-bridge-service/pom.xml deleted file mode 100644 index c1cd6fedc..000000000 --- a/event-bridge-service/pom.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - 4.0.0 - - com.baiyue.firstlegbaiyue-firstleg-platform - 1.0.0-SNAPSHOT../pom.xml - - event-bridge-service - event-bridge-service - - com.baiyue.sharedbaiyue-common-web${project.version} - com.baiyue.sharedbaiyue-common-openapi${project.version} - com.baiyue.sharedcommon-event-contract${project.version} - com.baomidoumybatis-plus-spring-boot3-starter - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config - org.springframework.bootspring-boot-starter-actuator - com.mysqlmysql-connector-jruntime - org.springframework.bootspring-boot-starter-testtest - - org.springframework.bootspring-boot-maven-plugin - diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java deleted file mode 100644 index 04f9c3db7..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.baiyue.eventbridge; - -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import org.apache.ibatis.annotations.Mapper; -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.EnableConfigurationProperties; - -/** - * 跨区域事件桥启动入口。 - */ -@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class) -@SpringBootApplication -@EnableConfigurationProperties(EventDispatcherProperties.class) -public class EventBridgeApplication { - public static void main(String[] args) { - SpringApplication.run(EventBridgeApplication.class, args); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java deleted file mode 100644 index fffbbea19..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.baiyue.eventbridge.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.net.URI; -import java.time.Duration; - -@ConfigurationProperties(prefix = "baiyue.event-dispatcher") -public record EventDispatcherProperties( - URI consumerUrl, - int batchSize, - int maxAttempts, - Duration leaseDuration, - Duration initialBackoff, - Duration maxBackoff, - Duration connectTimeout, - Duration requestTimeout) { - - public EventDispatcherProperties { - consumerUrl = consumerUrl == null - ? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl; - batchSize = batchSize <= 0 ? 50 : batchSize; - maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts; - leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30)); - initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5)); - maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15)); - connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3)); - requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10)); - } - - private static Duration positiveOrDefault(Duration value, Duration fallback) { - return value == null || value.isNegative() || value.isZero() ? fallback : value; - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java deleted file mode 100644 index 7a2b939f0..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.baiyue.eventbridge.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.eventbridge.dto.BridgeStatus; -import com.baiyue.eventbridge.dto.EventReceiveResult; -import com.baiyue.eventbridge.dto.EventReplayRequest; -import com.baiyue.eventbridge.service.EventInboxService; -import com.baiyue.eventbridge.service.EventReplayService; -import com.baiyue.eventbridge.security.InternalServiceTokenGuard; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.GetMapping; -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 org.springframework.web.bind.annotation.PathVariable; - -/** - * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 - */ -@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态") -@RestController -@RequestMapping("/internal/event-bridge") -public class EventBridgeController { - @Resource private EventInboxService eventInboxService; - @Resource private EventReplayService eventReplayService; - @Resource private InternalServiceTokenGuard internalServiceTokenGuard; - - @Value("${baiyue.region:OVERSEAS}") - private String region; - - @Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "内部服务令牌未配置,接收接口关闭") - }) - @PostMapping("/events") - public ApiResponse receive( - @Parameter(description = "内部服务共享令牌") - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestBody EventEnvelope envelope) { - internalServiceTokenGuard.verify(token); - return ApiResponse.success(eventInboxService.receive(envelope)); - } - - @Operation(summary = "人工补投死信事件", - description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因") - @PostMapping("/events/{eventId}/replay") - public ApiResponse replay( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestHeader("X-Operator-Id") String operatorId, - @PathVariable String eventId, - @RequestBody EventReplayRequest request) { - internalServiceTokenGuard.verify(token); - eventReplayService.replayDead(eventId, operatorId, request.reason()); - return ApiResponse.success(null); - } - - @Operation(summary = "查询事件桥能力状态") - @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) - @GetMapping("/status") - public ApiResponse status() { - return ApiResponse.success(new BridgeStatus("运行中", region, true, false)); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java deleted file mode 100644 index eaf57c555..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.baiyue.eventbridge.domain; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -@Data -@TableName("bridge_delivery_attempt") -public class BridgeDeliveryAttempt { - @TableId(type = IdType.AUTO) - private Long id; - private String eventId; - private String sourceRegion; - private String targetRegion; - private Integer attemptNo; - private String status; - private String responseCode; - private String errorMessage; - private LocalDateTime startedAt; - private LocalDateTime finishedAt; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java deleted file mode 100644 index 96f15d531..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.baiyue.eventbridge.domain; - -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; -import java.time.Instant; - -/** - * 事件桥入站幂等记录。 - */ -@Data -@TableName("bridge_inbox") -public class BridgeInbox { - @TableId - private String eventId; - private String idempotencyKey; - private String eventType; - private Integer eventVersion; - private String sourceRegion; - private String targetRegion; - private String customerId; - private String aggregateType; - private String aggregateId; - private Long aggregateVersion; - private Long handoverVersion; - private String traceId; - private String correlationId; - private String causationId; - private Instant occurredAt; - private String payloadHash; - private String payloadJson; - private String status; - private String leaseOwner; - private LocalDateTime leaseUntil; - private Integer retryCount; - private LocalDateTime nextRetryAt; - private String lastError; - private LocalDateTime receivedAt; - private LocalDateTime processedAt; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java deleted file mode 100644 index ff9d7efbc..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.eventbridge.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -/** - * 事件桥能力状态,不代表生产网络链路已经开通。 - */ -@Schema(description = "事件桥能力状态") -public record BridgeStatus( - @Schema(description = "服务状态") String status, - @Schema(description = "当前区域") String region, - @Schema(description = "数据库幂等接收是否启用") boolean inboxEnabled, - @Schema(description = "生产级跨区传输是否启用") boolean productionTransportEnabled) { -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java deleted file mode 100644 index 5d84a09df..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.baiyue.eventbridge.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.LocalDateTime; - -/** - * 事件接收结果。 - */ -@Schema(description = "事件接收结果") -public record EventReceiveResult( - @Schema(description = "事件标识") String eventId, - @Schema(description = "接收结果:已接收或重复") String result, - @Schema(description = "接收时间") LocalDateTime receivedAt) { -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java deleted file mode 100644 index 25c4bb324..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.baiyue.eventbridge.dto; - -public record EventReplayRequest(String reason) { -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java deleted file mode 100644 index 755341428..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.baiyue.eventbridge.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface BridgeDeliveryAttemptMapper extends BaseMapper { -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java deleted file mode 100644 index 287a5faa0..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.baiyue.eventbridge.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; - -import java.util.List; - -/** - * 入站事件持久化映射。 - */ -@Mapper -public interface BridgeInboxMapper extends BaseMapper { - @Select(""" - SELECT event_id FROM bridge_inbox - WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) - OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) - ORDER BY received_at LIMIT #{limit} - """) - List dispatchCandidates(@Param("limit") int limit); - - @Update(""" - UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, - lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) - WHERE event_id=#{eventId} - AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) - OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) - """) - int claim(@Param("eventId") String eventId, @Param("owner") String owner, - @Param("leaseSeconds") long leaseSeconds); - - @Update(""" - UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3), - lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL - WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} - """) - int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner); - - @Update(""" - UPDATE bridge_inbox SET retry_count=retry_count+1, - status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END, - next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL - ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END, - lease_owner=NULL, lease_until=NULL, last_error=#{error} - WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} - """) - int markFailed(@Param("eventId") String eventId, @Param("owner") String owner, - @Param("maxAttempts") int maxAttempts, - @Param("backoffSeconds") long backoffSeconds, @Param("error") String error); - - @Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}") - int nextAttemptNo(@Param("eventId") String eventId); - - @Update(""" - UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3), - lease_owner=NULL, lease_until=NULL, last_error=#{reason} - WHERE event_id=#{eventId} AND status='DEAD' - """) - int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); - - /** - * Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume; - * Prometheus collection never invokes this query directly. - */ - @Select(""" - SELECT target_region AS targetRegion, event_type AS eventType, - SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount, - COALESCE(TIMESTAMPDIFF(MICROSECOND, - MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END), - CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds, - SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount - FROM bridge_inbox - WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD') - GROUP BY target_region, event_type - """) - List observabilitySnapshots(); -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java deleted file mode 100644 index 667aaadb2..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.MultiGauge; -import io.micrometer.core.instrument.Tags; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.util.LinkedHashMap; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */ -@Component -@Slf4j -public class EventBridgeInboxMetricsSampler { - public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog"; - public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds"; - public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead"; - - private final BridgeInboxMapper inboxMapper; - private final MultiGauge backlog; - private final MultiGauge oldestAge; - private final MultiGauge dead; - - public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) { - this.inboxMapper = inboxMapper; - this.backlog = MultiGauge.builder(BACKLOG_METRIC) - .description("Events waiting for downstream acknowledgement").register(registry); - this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC) - .description("Age in seconds of the oldest pending Inbox event").register(registry); - this.dead = MultiGauge.builder(DEAD_METRIC) - .description("Current number of DEAD Inbox events").register(registry); - } - - @Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}", - fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}") - public void sample() { - final List snapshots; - try { - snapshots = inboxMapper.observabilitySnapshots(); - } catch (RuntimeException exception) { - log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception); - return; - } - Map aggregates = aggregate(snapshots); - backlog.register(rows(aggregates, MetricValue.BACKLOG), true); - oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true); - dead.register(rows(aggregates, MetricValue.DEAD), true); - } - - private Map aggregate(List snapshots) { - Map result = new LinkedHashMap<>(); - if (snapshots == null) { - return result; - } - for (InboxMetricsSnapshot snapshot : snapshots) { - MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()), - EventBridgeMetrics.eventType(snapshot.getEventType())); - result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot); - } - return result; - } - - private List> rows(Map aggregates, MetricValue value) { - List> rows = new ArrayList<>(); - aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of( - Tags.of("target_region", key.targetRegion, "event_type", key.eventType), - value.value(aggregate)))); - return rows; - } - - private record MetricKey(String targetRegion, String eventType) { } - - private static final class Aggregate { - private long backlog; - private double oldestAge; - private long dead; - - void add(InboxMetricsSnapshot snapshot) { - backlog += snapshot.getBacklogCount(); - oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds()); - dead += snapshot.getDeadCount(); - } - } - - private enum MetricValue { - BACKLOG { - @Override Number value(Aggregate aggregate) { return aggregate.backlog; } - }, - OLDEST_AGE { - @Override Number value(Aggregate aggregate) { return aggregate.oldestAge; } - }, - DEAD { - @Override Number value(Aggregate aggregate) { return aggregate.dead; } - }; - - abstract Number value(Aggregate aggregate); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java deleted file mode 100644 index 1b97d2bdf..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventTypes; -import com.baiyue.eventbridge.domain.BridgeInbox; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.time.Clock; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Locale; -import java.util.Set; - -/** Low-cardinality transport metrics for the cross-region event bridge. */ -@Component -@Slf4j -public class EventBridgeMetrics { - public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; - public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; - public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; - - private static final Set KNOWN_EVENT_TYPES = Set.of( - EventTypes.ORDER_APPROVED, - EventTypes.ORDER_CANCELLED, - EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, - EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, - EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, - EventTypes.OVERSEAS_CONTAINER_UNLOADED, - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, - EventTypes.OVERSEAS_DELIVERY_COMPLETED, - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, - EventTypes.WALLET_COMMAND_REQUESTED, - EventTypes.WALLET_COMMAND_COMPLETED, - EventTypes.IAM_USER_STATUS_CHANGED, - EventTypes.IAM_TENANT_STATUS_CHANGED, - EventTypes.IAM_PERMISSION_VERSION_CHANGED, - EventTypes.LAST_MILE_HANDOFF_ACCEPTED, - EventTypes.LAST_MILE_HANDOFF_REJECTED, - EventTypes.LAST_MILE_MILESTONE_CHANGED, - EventTypes.LAST_MILE_COMPLETED, - EventTypes.TRACKING_MILESTONE_PROJECTED); - - private final MeterRegistry registry; - private final Clock clock; - - @Autowired - public EventBridgeMetrics(MeterRegistry registry) { - this(registry, Clock.systemDefaultZone()); - } - - EventBridgeMetrics(MeterRegistry registry, Clock clock) { - this.registry = registry; - this.clock = clock; - } - - public void recordReceive(EventEnvelope envelope, ReceiveResult result) { - try { - registry.counter(RECEIVE_METRIC, - "target_region", targetRegion(envelope.targetRegion().name()), - "event_type", eventType(envelope.eventType()), - "result", result.name()).increment(); - } catch (RuntimeException exception) { - log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception); - } - } - - public void recordTransport(BridgeInbox event, TransportResult result) { - try { - String targetRegion = targetRegion(event.getTargetRegion()); - String eventType = eventType(event.getEventType()); - registry.counter(TRANSPORT_METRIC, - "target_region", targetRegion, - "event_type", eventType, - "result", result.name()).increment(); - if (result.acknowledged() && event.getReceivedAt() != null) { - Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock)); - if (latency.isNegative()) { - latency = Duration.ZERO; - } - Timer.builder(DELIVERY_LATENCY_METRIC) - .description("Time from Event Bridge receipt to downstream acknowledgement") - .serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15)) - .tags("target_region", targetRegion, "event_type", eventType) - .register(registry).record(latency); - } - } catch (RuntimeException exception) { - log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception); - } - } - - public static String targetRegion(String value) { - String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); - return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER"; - } - - public static String eventType(String value) { - return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER"; - } - - public enum ReceiveResult { - RECEIVED, - DUPLICATE, - CONFLICT - } - - public enum TransportResult { - DELIVERED(true), - RETRY(false), - DEAD(false), - ACKNOWLEDGED_LEASE_LOST(true), - FAILED_LEASE_LOST(false), - MANUAL_REPLAY(false); - - private final boolean acknowledged; - - TransportResult(boolean acknowledged) { - this.acknowledged = acknowledged; - } - - boolean acknowledged() { - return acknowledged; - } - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java deleted file mode 100644 index 24e89b303..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -/** Result row of the bounded, scheduled Inbox observability aggregation. */ -public class InboxMetricsSnapshot { - private String targetRegion; - private String eventType; - private long backlogCount; - private double oldestPendingAgeSeconds; - private long deadCount; - - public String getTargetRegion() { - return targetRegion; - } - - public void setTargetRegion(String targetRegion) { - this.targetRegion = targetRegion; - } - - public String getEventType() { - return eventType; - } - - public void setEventType(String eventType) { - this.eventType = eventType; - } - - public long getBacklogCount() { - return backlogCount; - } - - public void setBacklogCount(long backlogCount) { - this.backlogCount = backlogCount; - } - - public double getOldestPendingAgeSeconds() { - return oldestPendingAgeSeconds; - } - - public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) { - this.oldestPendingAgeSeconds = oldestPendingAgeSeconds; - } - - public long getDeadCount() { - return deadCount; - } - - public void setDeadCount(long deadCount) { - this.deadCount = deadCount; - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java deleted file mode 100644 index ba4717409..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.baiyue.eventbridge.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -/** - * 内部事件接收接口的共享令牌校验器;未配置令牌时默认关闭接收。 - */ -@Component -public class InternalServiceTokenGuard { - private final String expectedToken; - - public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { - this.expectedToken = expectedToken; - } - - public void verify(String actualToken) { - if (expectedToken == null || expectedToken.isBlank()) { - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "内部事件接收令牌未配置,接口已关闭"); - } - byte[] expected = expectedToken.getBytes(StandardCharsets.UTF_8); - byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expected, actual)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效"); - } - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java deleted file mode 100644 index 1a0538a19..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.dto.EventReceiveResult; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import jakarta.annotation.Resource; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Objects; - -/** - * 入站事件校验与幂等落库服务。 - */ -@Service -public class EventInboxService { - @Resource private InboxStore inboxStore; - @Resource private ObjectMapper objectMapper; - @Resource private EventBridgeMetrics metrics; - - @Value("${baiyue.region:CN}") - private String localRegion; - - /** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */ - @Value("${baiyue.event-bridge.accept-any-region:false}") - private boolean acceptAnyRegion; - - @Transactional - public EventReceiveResult receive(EventEnvelope envelope) { - envelope.requireValid(); - if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { - throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); - } - if (!EventPayloadHash.matches( - EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { - throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); - } - BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); - if (existing != null) { - return duplicateOrConflict(existing, envelope); - } - String idempotencyKey = idempotencyKey(envelope); - BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey); - if (sameBusinessEvent != null) { - return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope); - } - BridgeInbox inbox = toInbox(envelope); - try { - inboxStore.insert(inbox); - metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); - return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); - } catch (DuplicateKeyException exception) { - BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); - if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey); - if (concurrent == null) { - throw exception; - } - return Objects.equals(concurrent.getEventId(), envelope.eventId()) - ? duplicateOrConflict(concurrent, envelope) - : duplicateBusinessEventOrConflict(concurrent, envelope); - } - } - - private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!sameBusinessEvent(existing, envelope)) { - metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); - throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移"); - } - metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); - return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt()); - } - - private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!sameEnvelope(existing, envelope)) { - metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); - throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); - } - metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); - return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); - } - - private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { - return sameBusinessEvent(existing, envelope) - && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) - && Objects.equals(existing.getTraceId(), envelope.traceId()) - && Objects.equals(existing.getCausationId(), envelope.causationId()); - } - - private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) { - return existing.getPayloadHash() != null - && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) - && Objects.equals(existing.getEventType(), envelope.eventType()) - && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) - && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) - && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) - && Objects.equals(existing.getCustomerId(), envelope.tenantId()) - && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) - && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) - && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) - && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) - && Objects.equals(existing.getCorrelationId(), envelope.correlationId()); - } - - private BridgeInbox toInbox(EventEnvelope envelope) { - BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId(envelope.eventId()); - inbox.setIdempotencyKey(idempotencyKey(envelope)); - inbox.setEventType(envelope.eventType()); - inbox.setEventVersion(envelope.eventVersion()); - inbox.setSourceRegion(envelope.sourceRegion().name()); - inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setCustomerId(envelope.tenantId()); - inbox.setAggregateType(envelope.aggregateType()); - inbox.setAggregateId(envelope.aggregateId()); - inbox.setAggregateVersion(envelope.aggregateVersion()); - inbox.setHandoverVersion(envelope.handoverVersion()); - inbox.setTraceId(envelope.traceId()); - inbox.setCorrelationId(envelope.correlationId()); - inbox.setCausationId(envelope.causationId()); - inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt())); - inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); - try { - inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); - } catch (JsonProcessingException exception) { - throw new IllegalArgumentException("事件负载无法序列化", exception); - } - inbox.setStatus("RECEIVED"); - inbox.setReceivedAt(LocalDateTime.now()); - return inbox; - } - - private static Instant normalizeOccurredAt(Instant value) { - return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); - } - - private static String idempotencyKey(EventEnvelope envelope) { - return envelope.sourceRegion() + ":" + envelope.correlationId(); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java deleted file mode 100644 index 632672e95..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Service -public class EventReplayService { - @Resource private BridgeInboxMapper inboxMapper; - @Resource private BridgeDeliveryAttemptMapper attemptMapper; - @Resource private EventBridgeMetrics metrics; - - @Transactional - public void replayDead(String eventId, String operatorId, String reason) { - if (operatorId == null || operatorId.isBlank()) { - throw new IllegalArgumentException("人工补投必须提供操作人"); - } - if (reason == null || reason.isBlank()) { - throw new IllegalArgumentException("人工补投必须提供原因"); - } - String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000); - if (inboxMapper.replayDead(eventId, auditReason) != 1) { - throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); - } - BridgeInbox event = inboxMapper.selectById(eventId); - if (event == null) { - throw new IllegalStateException("事件补投状态已更新但事件记录不存在"); - } - LocalDateTime now = LocalDateTime.now(); - BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt(); - audit.setEventId(eventId); - audit.setSourceRegion(event.getSourceRegion()); - audit.setTargetRegion(event.getTargetRegion()); - audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId)); - audit.setStatus("MANUAL_REPLAY"); - audit.setErrorMessage(auditReason); - audit.setStartedAt(now); - audit.setFinishedAt(now); - attemptMapper.insert(audit); - metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY); - } - - private static String abbreviate(String value, int maxLength) { - return value.length() <= maxLength ? value : value.substring(0, maxLength); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java deleted file mode 100644 index a8d4e7107..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; - -public interface FinanceEventClient { - String deliver(BridgeInbox event) throws Exception; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java deleted file mode 100644 index a0bb0e33e..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; - -public interface FirstlegEventClient { - String deliver(BridgeInbox event) throws Exception; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java deleted file mode 100644 index e86b022f9..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -@Component -public class HttpFinanceEventClient implements FinanceEventClient { - @Resource private ObjectMapper objectMapper; - @Resource private EventDispatcherProperties properties; - @Resource private EventBridgeHttpTracePropagation tracePropagation; - @Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}") - private URI consumerUrl; - @Value("${baiyue.event-dispatcher.finance-wallet-consumer-url:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}") - private URI walletConsumerUrl; - @Value("${baiyue.security.internal-service-token:}") private String serviceToken; - private HttpClient httpClient; - - @PostConstruct - void initializeHttpClient() { - httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); - } - - @Override - public String deliver(BridgeInbox row) throws Exception { - EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), - row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), - row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), - row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), - objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - URI target = EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType()) - ? walletConsumerUrl : consumerUrl; - HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(target) - .timeout(properties.requestTimeout()).header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken)) - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode body; - try { body = objectMapper.readTree(response.body()); } - catch (Exception ignored) { body = null; } - if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) { - throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode() - + ", code=" + (body == null ? "missing" : body.path("code").asText("missing"))); - } - return String.valueOf(response.statusCode()); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java deleted file mode 100644 index f888cd04f..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.RegionCode; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -@Component -public class HttpFirstlegEventClient implements FirstlegEventClient { - @Resource private ObjectMapper objectMapper; - @Resource private EventDispatcherProperties properties; - @Resource private EventBridgeHttpTracePropagation tracePropagation; - @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") - private URI consumerUrl; - @Value("${baiyue.security.internal-service-token:}") private String serviceToken; - private HttpClient httpClient; - - @PostConstruct - void initializeHttpClient() { - httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); - } - - @Override - public String deliver(BridgeInbox row) throws Exception { - EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), - row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), - row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), - row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), - objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) - .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken); - HttpRequest request = tracePropagation.apply(requestBuilder) - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody; - try { - responseBody = objectMapper.readTree(response.body()); - } catch (Exception ignored) { - responseBody = null; - } - if (response.statusCode() / 100 != 2 || responseBody == null - || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); - } - return String.valueOf(response.statusCode()); - } - - static String failureMessage(int statusCode, JsonNode body) { - String code = body == null ? "missing" : body.path("code").asText("missing"); - String message = body == null ? "non-json response" : body.path("message").asText("missing"); - String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); - String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); - return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code) - + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) - + ", traceId=" + bounded(traceId); - } - - private static String bounded(String value) { - String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); - return safe.length() <= 240 ? safe : safe.substring(0, 240); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java deleted file mode 100644 index 2e01d3981..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.RegionCode; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.Resource; - -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -@Component -public class HttpLastlegEventClient implements LastlegEventClient { - @Resource private ObjectMapper objectMapper; - @Resource private EventDispatcherProperties properties; - @Resource private EventBridgeHttpTracePropagation tracePropagation; - - @Value("${baiyue.security.internal-service-token:}") - private String serviceToken; - - private HttpClient httpClient; - - @PostConstruct - void initializeHttpClient() { - this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); - } - - @Override - public String deliver(BridgeInbox row) throws Exception { - EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), - row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), - row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), - row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), - objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - String body = objectMapper.writeValueAsString(envelope); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) - .timeout(properties.requestTimeout()) - .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken); - HttpRequest request = tracePropagation.apply(requestBuilder) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody; - try { - responseBody = objectMapper.readTree(response.body()); - } catch (Exception ignored) { - responseBody = null; - } - if (response.statusCode() / 100 != 2 || responseBody == null - || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); - } - return String.valueOf(response.statusCode()); - } - - static String failureMessage(int statusCode, JsonNode body) { - String code = body == null ? "missing" : body.path("code").asText("missing"); - String message = body == null ? "non-json response" : body.path("message").asText("missing"); - String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); - String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); - return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code) - + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) - + ", traceId=" + bounded(traceId); - } - - private static String bounded(String value) { - String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); - return safe.length() <= 240 ? safe : safe.substring(0, 240); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java deleted file mode 100644 index 9e55d56bd..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.RegionCode; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -@Component -public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { - private final ObjectMapper objectMapper; - private final EventDispatcherProperties properties; - @Resource private EventBridgeHttpTracePropagation tracePropagation; - @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; - @Value("${baiyue.security.internal-service-token:}") private String serviceToken; - private HttpClient httpClient; - - public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) { - this.objectMapper = objectMapper; - this.properties = properties; - } - - @PostConstruct void initialize() { - httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); - } - - @Override - public String deliver(BridgeInbox row) throws Exception { - EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), - row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), - row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), - row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), - objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) - .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken); - HttpRequest request = tracePropagation.apply(requestBuilder) - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode body; - try { body = objectMapper.readTree(response.body()); } - catch (Exception exception) { body = null; } - requireAcknowledgement(response.statusCode(), body, row.getEventId()); - return String.valueOf(response.statusCode()); - } - - static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { - if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) - || !eventId.equals(body.path("data").path("eventId").asText())) { - throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); - } - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java deleted file mode 100644 index b542caebb..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; - -/** - * 入站幂等存储边界,便于隔离数据库实现并进行单元测试。 - */ -public interface InboxStore { - BridgeInbox findByEventId(String eventId); - BridgeInbox findByIdempotencyKey(String idempotencyKey); - void insert(BridgeInbox inbox); -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java deleted file mode 100644 index 7fa21d3b3..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; - -public interface LastlegEventClient { - String deliver(BridgeInbox event) throws Exception; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java deleted file mode 100644 index fedcdf41c..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; - -/** - * 基于 MyBatis-Plus 的入站幂等存储。 - */ -@Repository -public class MybatisInboxStore implements InboxStore { - @Resource private BridgeInboxMapper mapper; - - @Override - public BridgeInbox findByEventId(String eventId) { - return mapper.selectById(eventId); - } - - @Override - public BridgeInbox findByIdempotencyKey(String idempotencyKey) { - return mapper.selectOne(new LambdaQueryWrapper() - .eq(BridgeInbox::getIdempotencyKey, idempotencyKey)); - } - - @Override - public void insert(BridgeInbox inbox) { - mapper.insert(inbox); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java deleted file mode 100644 index 17ff106b7..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventTypes; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import com.baiyue.eventbridge.trace.EventBridgeTraceScope; -import com.baiyue.common.web.CorrelationContext; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.slf4j.MDC; - -import java.lang.management.ManagementFactory; -import java.time.LocalDateTime; -import java.util.Set; -import java.util.UUID; - -@Component -@Slf4j -public class OverseasEventDispatcher { - @Resource private BridgeInboxMapper inboxMapper; - @Resource private BridgeDeliveryAttemptMapper attemptMapper; - @Resource private LastlegEventClient eventClient; - @Resource private FirstlegEventClient firstlegEventClient; - @Resource private FinanceEventClient financeEventClient; - @Resource private SecurityProjectionEventClient securityProjectionEventClient; - @Resource private EventDispatcherProperties properties; - @Resource private EventBridgeMetrics metrics; - private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); - - @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") - public void dispatch() { - for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { - dispatchOne(eventId); - } - } - - void dispatchOne(String eventId) { - if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) { - return; - } - - BridgeInbox event = inboxMapper.selectById(eventId); - try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { - dispatchClaimed(eventId, event); - } - } - - private void dispatchClaimed(String eventId, BridgeInbox event) { - int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; - int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); - BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); - String responseCode; - try { - responseCode = deliver(event); - } catch (Exception exception) { - String error = abbreviate(exception.getMessage(), 1000); - int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), - backoffSeconds(retryAttempt), error); - String attemptStatus = marked == 1 - ? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY") - : "FAILED_LEASE_LOST"; - finishAttempt(attempt, attemptStatus, null, error); - metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); - log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", - eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); - return; - } - - if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { - String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; - finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); - metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); - log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", - eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); - return; - } - finishAttempt(attempt, "DELIVERED", responseCode, null); - metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); - } - - private String deliver(BridgeInbox event) throws Exception { - String eventType = event.getEventType(); - if (eventType == null || eventType.isBlank()) { - throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); - } - return switch (event.getTargetRegion()) { - case "CN" -> FINANCE_EVENT_TYPES.contains(eventType) - ? financeEventClient.deliver(event) : firstlegEventClient.deliver(event); - case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) - ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); - default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); - }; - } - - private static final Set SECURITY_EVENT_TYPES = Set.of( - EventTypes.IAM_USER_STATUS_CHANGED, - EventTypes.IAM_TENANT_STATUS_CHANGED, - EventTypes.IAM_PERMISSION_VERSION_CHANGED); - - private static final Set FINANCE_EVENT_TYPES = Set.of( - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, - EventTypes.WALLET_COMMAND_REQUESTED); - - private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { - BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); - attempt.setEventId(event.getEventId()); - attempt.setSourceRegion(event.getSourceRegion()); - attempt.setTargetRegion(event.getTargetRegion()); - attempt.setAttemptNo(attemptNo); - attempt.setStatus("PROCESSING"); - attempt.setStartedAt(LocalDateTime.now()); - attemptMapper.insert(attempt); - return attempt; - } - - private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) { - attempt.setStatus(status); - attempt.setResponseCode(responseCode); - attempt.setErrorMessage(error); - attempt.setFinishedAt(LocalDateTime.now()); - attemptMapper.updateById(attempt); - } - - private long backoffSeconds(int attemptNo) { - long initial = properties.initialBackoff().toSeconds(); - int shift = Math.min(Math.max(attemptNo - 1, 0), 20); - long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift; - return Math.min(delay, properties.maxBackoff().toSeconds()); - } - - private static String abbreviate(String value, int maxLength) { - String safe = value == null || value.isBlank() ? "未知投递错误" : value; - return safe.length() <= maxLength ? safe : safe.substring(0, maxLength); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java deleted file mode 100644 index 87fd9e3eb..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeInbox; - -public interface SecurityProjectionEventClient { - String deliver(BridgeInbox event) throws Exception; -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java deleted file mode 100644 index dc2c78bd3..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.eventbridge.trace; - -import com.baiyue.common.web.CorrelationContext; -import com.baiyue.common.web.OutboundTraceHeaders; -import org.springframework.stereotype.Component; - -import java.net.http.HttpRequest; - -/** Applies a fresh child span to one raw JDK HttpClient request. */ -@Component -public class EventBridgeHttpTracePropagation { - public HttpRequest.Builder apply(HttpRequest.Builder builder) { - OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); - return builder - .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) - .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) - .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); - } -} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java deleted file mode 100644 index 82139ab51..000000000 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.baiyue.eventbridge.trace; - -import com.baiyue.common.web.CorrelationContext; -import com.baiyue.common.web.OutboundTraceHeaders; -import com.baiyue.eventbridge.domain.BridgeInbox; -import org.slf4j.MDC; - -import java.util.Map; - -/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ -public final class EventBridgeTraceScope implements AutoCloseable { - private final Map previous; - - private EventBridgeTraceScope(Map previous) { - this.previous = previous; - } - - public static EventBridgeTraceScope open(BridgeInbox event) { - Map previous = MDC.getCopyOfContextMap(); - OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); - MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); - MDC.put(CorrelationContext.TRACE_ID, local.traceId()); - MDC.put(CorrelationContext.SPAN_ID, local.spanId()); - return new EventBridgeTraceScope(previous); - } - - @Override - public void close() { - if (previous == null) { - MDC.clear(); - } else { - MDC.setContextMap(previous); - } - } -} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml deleted file mode 100644 index f16fd66fc..000000000 --- a/event-bridge-service/src/main/resources/application.yml +++ /dev/null @@ -1,60 +0,0 @@ -server: - port: ${EVENT_BRIDGE_PORT:9312} - -spring: - data: - redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} - application: - name: event-bridge-service - config: - import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} - cloud: - nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - discovery: - enabled: ${NACOS_DISCOVERY_ENABLED:true} - register-enabled: ${NACOS_REGISTER_ENABLED:true} - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:} - config: - namespace: ${NACOS_NAMESPACE:} - -baiyue: - region: ${REGION_CODE:CN} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} - firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} - finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events} - finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events} - security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} - batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} - max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} - lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} - initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} - max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} - connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} - request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:} - -management: - endpoints: - web: - exposure: - include: health,info,prometheus - -springdoc: - api-docs: - path: /v3/api-docs diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql deleted file mode 100644 index 300f674ec..000000000 --- a/event-bridge-service/src/main/resources/db/event-bridge.sql +++ /dev/null @@ -1,80 +0,0 @@ -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge; - -CREATE TABLE IF NOT EXISTS bridge_outbox ( - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - event_version INT NOT NULL COMMENT '事件契约版本', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', - aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', - aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', - aggregate_version BIGINT NOT NULL COMMENT '聚合版本', - handover_version BIGINT NULL COMMENT '跨区交接版本', - trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', - correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', - causation_id VARCHAR(128) NULL COMMENT '上游事件标识', - occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', - payload_json JSON NOT NULL COMMENT '事件负载', - status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', - lease_owner VARCHAR(128) NULL COMMENT '租约持有者', - lease_until DATETIME(3) NULL COMMENT '租约截止时间', - last_error VARCHAR(1000) NULL COMMENT '最后错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at DATETIME(3) NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until) -) ENGINE=InnoDB COMMENT='跨区域事件待投递箱'; - -CREATE TABLE IF NOT EXISTS bridge_inbox ( - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - event_version INT NOT NULL COMMENT '事件契约版本', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', - aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', - aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', - aggregate_version BIGINT NOT NULL COMMENT '聚合版本', - handover_version BIGINT NULL COMMENT '跨区交接版本', - trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', - correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', - causation_id VARCHAR(128) NULL COMMENT '上游事件标识', - occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', - payload_json JSON NOT NULL COMMENT '事件负载', - status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', - lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', - retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', - last_error VARCHAR(1000) NULL COMMENT '最后错误', - received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at DATETIME(3) NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) -) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; - -CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - source_region VARCHAR(32) NOT NULL COMMENT '来源区域', - target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - attempt_no INT NOT NULL COMMENT '投递序号', - status VARCHAR(32) NOT NULL COMMENT '投递结果', - response_code VARCHAR(64) NULL COMMENT '对端响应码', - error_message VARCHAR(1000) NULL COMMENT '错误信息', - started_at DATETIME(3) NOT NULL COMMENT '开始时间', - finished_at DATETIME(3) NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), - KEY idx_bridge_attempt_event (event_id, started_at) -) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java deleted file mode 100644 index a18746bd5..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -import com.baiyue.common.event.EventTypes; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -class EventBridgeInboxMetricsSamplerTest { - - @Test - void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() { - BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, - 7, 121.5, 2); - InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1); - when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown)); - EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry); - - sampler.sample(); - - verify(mapper).observabilitySnapshots(); - assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC, - "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0); - assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC, - "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5); - assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC, - "OTHER", "OTHER")).isEqualTo(1.0); - verifyNoMoreInteractions(mapper); - } - - private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog, - double oldestAge, long dead) { - InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot(); - snapshot.setTargetRegion(region); - snapshot.setEventType(eventType); - snapshot.setBacklogCount(backlog); - snapshot.setOldestPendingAgeSeconds(oldestAge); - snapshot.setDeadCount(dead); - return snapshot; - } - - private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) { - return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value(); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java deleted file mode 100644 index 636db964f..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -import com.baiyue.eventbridge.domain.BridgeInbox; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import io.micrometer.core.instrument.MeterRegistry; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Modifier; -import java.util.Arrays; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.Mockito.mock; - -class EventBridgeMetricsTest { - - @Test - void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { - assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) - .isAnnotationPresent(Autowired.class)).isTrue(); - } - - @Test - void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); - context.register(EventBridgeMetrics.class); - context.refresh(); - - assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); - } - } - - @Test - void collapsesUnboundedRegionAndEventTypeLabelsToOther() { - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - EventBridgeMetrics metrics = new EventBridgeMetrics(registry); - BridgeInbox event = new BridgeInbox(); - event.setTargetRegion("customer-controlled-region"); - event.setEventType("dynamic-event-123456"); - - metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY); - - assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) - .tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY") - .counter().count()).isEqualTo(1.0); - assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) - .noneMatch(tag -> tag.getValue().contains("dynamic-event"))); - assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER"); - } - - @Test - void everyFrozenEventTypeHasABoundedTagValue() { - assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields()) - .filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) - .map(field -> { - try { - return (String) field.get(null); - } catch (IllegalAccessException exception) { - throw new AssertionError(exception); - } - })).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value)); - } - - @Test - void metricsFailureDoesNotChangeTransportControlFlow() { - EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class)); - BridgeInbox event = new BridgeInbox(); - event.setTargetRegion("CN"); - event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED); - - assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY)) - .doesNotThrowAnyException(); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java deleted file mode 100644 index 5c106df15..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.baiyue.eventbridge.metrics; - -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import org.apache.ibatis.annotations.Select; -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 EventBridgeObservabilityContractTest { - - @Test - void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception { - Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots") - .getAnnotation(Select.class); - String query = String.join(" ", annotation.value()).replaceAll("\\s+", " "); - String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql")); - - assertThat(query) - .contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')") - .contains("GROUP BY target_region, event_type") - .doesNotContain("event_id AS", "customer_id AS", "last_error AS"); - assertThat(schema).contains( - "KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)"); - } - - @Test - void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception { - String contract = Files.readString(Path.of("docs/development-observability-slo.md")); - - assertThat(contract) - .contains("99% 的新接收跨区事件在 60 秒内") - .contains("99.9% 的新接收跨区事件在 15 分钟内") - .contains("result=\"DEAD\"", "> 120", "> 900") - .contains("不表示任何环境已经部署") - .contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)") - .doesNotContain("customer_id\"", "event_id\""); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java deleted file mode 100644 index 1b8dfb9d2..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.baiyue.eventbridge.security; - -import org.junit.jupiter.api.Test; -import org.springframework.web.server.ResponseStatusException; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class InternalServiceTokenGuardTest { - @Test - void 正确令牌允许访问() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); - assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); - } - - @Test - void 未配置令牌时默认关闭接收接口() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); - assertThatThrownBy(() -> guard.verify("anything")) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("接口已关闭"); - } - - @Test - void 错误令牌被拒绝() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); - assertThatThrownBy(() -> guard.verify("wrong")) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("令牌无效"); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java deleted file mode 100644 index 0869ea824..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; - -class EventBridgeReliabilitySqlContractTest { - @Test - void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception { - String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class); - String claim = sql(BridgeInboxMapper.class.getMethod( - "claim", String.class, String.class, long.class), Update.class); - String failed = sql(BridgeInboxMapper.class.getMethod( - "markFailed", String.class, String.class, int.class, long.class, String.class), Update.class); - String processed = sql(BridgeInboxMapper.class.getMethod( - "markProcessed", String.class, String.class), Update.class); - - assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)", - "status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at"); - assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD", - "status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)"); - assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'", - "next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}"); - assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL", - "status='PROCESSING'", "lease_owner=#{owner}"); - } - - private String sql(Method method, Class type) { - A annotation = method.getAnnotation(type); - String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value(); - return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " "); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java deleted file mode 100644 index 3639dd0e5..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.RegionCode; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.Test; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.Instant; -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.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class EventInboxServiceTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - private final InboxStore store = mock(InboxStore.class); - private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); - private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); - private final EventInboxService service = service("OVERSEAS"); - - @Test - void 首次事件落库并返回已接收() { - EventEnvelope envelope = envelope("sha256:placeholder"); - envelope = withHash(envelope); - - assertThat(service.receive(envelope).result()).isEqualTo("已接收"); - verify(store).insert(any(BridgeInbox.class)); - assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); - } - - @Test - void 拒绝旧数字规范化摘要() { - EventEnvelope source = envelope("sha256:placeholder"); - source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); - String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); - EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), - source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), - source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); - - assertThatThrownBy(() -> service.receive(envelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("payloadHash"); - } - - @Test - void 相同事件和摘要重复接收不再次写入() { - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope, envelope.payloadHash()); - when(store.findByEventId(envelope.eventId())).thenReturn(existing); - - assertThat(service.receive(envelope).result()).isEqualTo("重复"); - assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); - } - - @Test - void 数据库毫秒精度截断后相同事件仍判定为重复() { - EventEnvelope source = envelope("sha256:placeholder"); - EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(), - source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), - source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), - source.payload(), source.payloadHash()); - EventEnvelope envelope = withHash(precise); - BridgeInbox existing = existing(envelope, envelope.payloadHash()); - existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing); - - assertThat(service.receive(envelope).result()).isEqualTo("重复"); - } - - @Test - void 相同事件但摘要不同时拒绝覆盖() { - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); - - assertThatThrownBy(() -> service.receive(envelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("拒绝覆盖"); - assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); - } - - @Test - void 相同事件和摘要但聚合标识不同时拒绝覆盖() { - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), - envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), - envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(), - envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); - when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash())); - - assertThatThrownBy(() -> service.receive(conflicting)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("事件信封"); - } - - @Test - void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() { - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope, envelope.payloadHash()); - when(store.findByEventId(envelope.eventId())).thenReturn(null); - when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing); - EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001", - envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"), - envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), - envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2", - envelope.correlationId(), null, envelope.payload(), envelope.payloadHash()); - assertThat(service.receive(replay).result()).isEqualTo("重复"); - - com.fasterxml.jackson.databind.node.ObjectNode driftPayload = - (com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy(); - driftPayload.put("containerNo", "DIFFERENT"); - String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload)); - EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002", - envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(), - envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(), - envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(), - null, driftPayload, driftHash); - assertThatThrownBy(() -> service.receive(drift)) - .hasMessageContaining("eventKey").hasMessageContaining("漂移"); - } - - @Test - void 并发插入冲突后按既有记录判定为重复() { - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash())); - doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); - - assertThat(service.receive(envelope).result()).isEqualTo("重复"); - } - - @Test - void 目标区域与当前部署区域不一致时拒绝接收() { - EventInboxService cnService = service("CN"); - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - - assertThatThrownBy(() -> cnService.receive(envelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("目标区域"); - } - - @Test - void 本地单实例模式允许接收不同目标区域事件() { - EventInboxService localService = service("CN", true); - EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - - assertThat(localService.receive(envelope).result()).isEqualTo("已接收"); - verify(store).insert(any(BridgeInbox.class)); - } - - private EventEnvelope envelope(String hash) { - return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1, - Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS, - "20001", "ORDER", "90001", 1, null, "trace-1", "flow-1", null, - objectMapper.createObjectNode().put("orderId", "90001"), hash); - } - - private EventInboxService service(String region) { - return service(region, false); - } - - private EventInboxService service(String region, boolean acceptAnyRegion) { - EventInboxService service = new EventInboxService(); - ReflectionTestUtils.setField(service, "inboxStore", store); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); - ReflectionTestUtils.setField(service, "metrics", metrics); - ReflectionTestUtils.setField(service, "localRegion", region); - ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion); - return service; - } - - private double receiveCount(String result) { - return registry.get(EventBridgeMetrics.RECEIVE_METRIC) - .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) - .counter().count(); - } - - private EventEnvelope withHash(EventEnvelope source) { - String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), - source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), hash); - } - - private BridgeInbox existing(EventEnvelope envelope, String hash) { - BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId(envelope.eventId()); - inbox.setEventType(envelope.eventType()); - inbox.setEventVersion(envelope.eventVersion()); - inbox.setSourceRegion(envelope.sourceRegion().name()); - inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setCustomerId(envelope.tenantId()); - inbox.setAggregateType(envelope.aggregateType()); - inbox.setAggregateId(envelope.aggregateId()); - inbox.setAggregateVersion(envelope.aggregateVersion()); - inbox.setHandoverVersion(envelope.handoverVersion()); - inbox.setOccurredAt(envelope.occurredAt()); - inbox.setTraceId(envelope.traceId()); - inbox.setCorrelationId(envelope.correlationId()); - inbox.setCausationId(envelope.causationId()); - inbox.setPayloadHash(hash); - inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); - return inbox; - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java deleted file mode 100644 index 49c00c1c8..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.apache.ibatis.annotations.Update; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -class EventReplayServiceTest { - private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); - private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); - private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); - private final EventReplayService service = service(); - - @Test - void requeuesDeadEventWithAuditReason() { - when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); - BridgeInbox event = new BridgeInbox(); - event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); - event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); - when(mapper.selectById("event-1")).thenReturn(event); - when(mapper.nextAttemptNo("event-1")).thenReturn(4); - - service.replayDead("event-1", "operator-7", "incident-42 approved"); - - verify(mapper).replayDead(eq("event-1"), contains("operator-7")); - var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); - verify(attemptMapper).insert(captor.capture()); - assertThat(captor.getValue().getAttemptNo()).isEqualTo(4); - assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY"); - assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42"); - assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) - .tags("target_region", "OVERSEAS", "event_type", event.getEventType(), - "result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0); - } - - @Test - void rejectsEventThatIsNotDead() { - when(mapper.replayDead(anyString(), anyString())).thenReturn(0); - - assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry")) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception { - String sql = String.join(" ", BridgeInboxMapper.class - .getMethod("replayDead", String.class, String.class) - .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); - - assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'") - .doesNotContain("payload_json=", "payload_hash="); - } - - private EventReplayService service() { - EventReplayService service = new EventReplayService(); - ReflectionTestUtils.setField(service, "inboxMapper", mapper); - ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); - ReflectionTestUtils.setField(service, "metrics", metrics); - return service; - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java deleted file mode 100644 index cac61af80..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.eventbridge.service; - -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 HttpFinanceEventClientTest { - @Test - void walletConsumerUrlCanBeConfiguredForContainerDeployment() throws Exception { - String application = Files.readString(Path.of("src/main/resources/application.yml")); - - assertThat(application).contains( - "finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:" - + "http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}"); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java deleted file mode 100644 index 69ed8424a..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class HttpFirstlegEventClientTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { - var body = objectMapper.readTree(""" - {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} - """); - - assertThat(HttpFirstlegEventClient.failureMessage(200, body)) - .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); - } - - @Test - void nonJsonFailureIsStillDiagnosableWithoutRawBody() { - assertThat(HttpFirstlegEventClient.failureMessage(503, null)) - .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java deleted file mode 100644 index b2de28029..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class HttpLastlegEventClientTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception { - var body = objectMapper.readTree(""" - {"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"} - """); - - assertThat(HttpLastlegEventClient.failureMessage(200, body)) - .isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1"); - } - - @Test - void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() { - assertThat(HttpLastlegEventClient.failureMessage(502, null)) - .isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing"); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java deleted file mode 100644 index 9a31b6db0..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class HttpSecurityProjectionEventClientTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { - var acknowledged = objectMapper.readTree(""" - {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} - """); - var businessFailure = objectMapper.readTree(""" - {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} - """); - var wrongEvent = objectMapper.readTree(""" - {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} - """); - - assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( - 200, acknowledged, "event-1")).doesNotThrowAnyException(); - assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( - 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( - 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); - } - - @Test - void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { - String application = Files.readString(Path.of("src/main/resources/application.yml")); - - assertThat(application).contains( - "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") - .doesNotContain("/internal/overseas-security/v1/events"); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java deleted file mode 100644 index 4262a7bc5..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ /dev/null @@ -1,313 +0,0 @@ -package com.baiyue.eventbridge.service; - -import com.baiyue.common.event.EventTypes; -import com.baiyue.eventbridge.config.EventDispatcherProperties; -import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; -import com.baiyue.eventbridge.domain.BridgeInbox; -import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; -import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import com.baiyue.eventbridge.metrics.EventBridgeMetrics; -import com.baiyue.common.web.CorrelationContext; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.test.util.ReflectionTestUtils; -import org.slf4j.MDC; - -import java.net.URI; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class OverseasEventDispatcherTest { - private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); - private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = - mock(com.baiyue.eventbridge.service.LastlegEventClient.class); - private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = - mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); - private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class); - private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); - private OverseasEventDispatcher dispatcher; - private BridgeInbox event; - private SimpleMeterRegistry registry; - - @BeforeEach - void setUp() { - EventDispatcherProperties properties = new EventDispatcherProperties( - URI.create("https://lastleg.internal/events"), 50, 3, - Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), - Duration.ofSeconds(3), Duration.ofSeconds(10)); - dispatcher = new OverseasEventDispatcher(); - registry = new SimpleMeterRegistry(); - ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); - ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); - ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); - ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); - ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient); - ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); - ReflectionTestUtils.setField(dispatcher, "properties", properties); - ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); - event = new BridgeInbox(); - event.setEventId("event-1"); - event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); - event.setSourceRegion("CN"); - event.setTargetRegion("OVERSEAS"); - event.setRetryCount(0); - event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); - when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); - when(inboxMapper.selectById("event-1")).thenReturn(event); - when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); - } - - @Test - void successfulDeliveryIsConfirmedAndAudited() throws Exception { - event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); - when(eventClient.deliver(event)).thenAnswer(invocation -> { - assertThat(MDC.get(CorrelationContext.TRACE_ID)) - .isEqualTo("550e8400e29b41d4a716446655440000"); - return "200"; - }); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(inboxMapper).markProcessed(eq("event-1"), anyString()); - verify(eventClient).deliver(event); - verifyNoInteractions(securityProjectionEventClient); - assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); - assertThat(transportCount("DELIVERED")).isEqualTo(1.0); - assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); - assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); - } - - @Test - void failedDeliveryIsScheduledForRetry() throws Exception { - when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) - .thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down")); - assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); - assertThat(transportCount("RETRY")).isEqualTo(1.0); - } - - @Test - void finalFailureIsMovedToDeadState() throws Exception { - event.setRetryCount(2); - when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) - .thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down")); - assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); - assertThat(transportCount("DEAD")).isEqualTo(1.0); - } - - @Test - void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception { - event.setRetryCount(4); - when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) - .thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down")); - } - - @Test - void eventClaimedByAnotherInstanceIsSkipped() { - when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0); - - dispatcher.dispatchOne("event-1"); - - verifyNoInteractions(eventClient); - verifyNoInteractions(firstlegEventClient); - verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); - } - - @Test - void cnTargetIsDeliveredToFirstleg() throws Exception { - event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); - event.setSourceRegion("OVERSEAS"); - event.setTargetRegion("CN"); - when(firstlegEventClient.deliver(event)).thenReturn("200"); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(firstlegEventClient).deliver(event); - verifyNoInteractions(eventClient); - verifyNoInteractions(securityProjectionEventClient); - assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); - } - - @Test - void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception { - event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); - event.setSourceRegion("OVERSEAS"); - event.setTargetRegion("CN"); - when(financeEventClient.deliver(event)).thenReturn("200"); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verify(financeEventClient).deliver(event); - verifyNoInteractions(firstlegEventClient); - verifyNoInteractions(eventClient); - assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); - } - - @Test - void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { - for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, - EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { - reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); - event.setEventType(eventType); - when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); - when(inboxMapper.selectById("event-1")).thenReturn(event); - when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); - when(securityProjectionEventClient.deliver(event)).thenReturn("200"); - - dispatcher.dispatchOne("event-1"); - - verify(securityProjectionEventClient).deliver(event); - verifyNoInteractions(eventClient); - verifyNoInteractions(firstlegEventClient); - } - } - - @Test - void unsupportedTargetRegionIsNeverMisroutedToLastleg() { - event.setTargetRegion("UNKNOWN"); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) - .thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verifyNoInteractions(eventClient); - verifyNoInteractions(firstlegEventClient); - verifyNoInteractions(securityProjectionEventClient); - assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); - } - - @Test - void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { - event.setEventType(null); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), - eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); - - dispatcher.dispatchOne("event-1"); - - verifyNoInteractions(eventClient); - verifyNoInteractions(firstlegEventClient); - verifyNoInteractions(securityProjectionEventClient); - BridgeDeliveryAttempt attempt = updatedAttempt(); - assertThat(attempt.getStatus()).isEqualTo("RETRY"); - assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); - } - - @Test - void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { - when(eventClient.deliver(event)).thenReturn("200"); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); - - dispatcher.dispatchOne("event-1"); - - verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); - BridgeDeliveryAttempt attempt = updatedAttempt(); - assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); - assertThat(attempt.getResponseCode()).isEqualTo("200"); - assertThat(attempt.getErrorMessage()).contains("租约已失效"); - assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); - } - - @Test - void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { - when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); - when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) - .thenReturn(0); - - dispatcher.dispatchOne("event-1"); - - assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); - assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); - } - - @Test - void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { - for (String target : List.of("OVERSEAS", "CN")) { - reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient); - event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN"); - event.setTargetRegion(target); - event.setRetryCount(0); - when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); - when(inboxMapper.selectById("event-1")).thenReturn(event); - when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2); - doAnswer(invocation -> { - event.setRetryCount(1); - event.setStatus("RETRY"); - event.setLastError(invocation.getArgument(4)); - event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds( - ((Number) invocation.getArgument(3)).longValue())); - return 1; - }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString()); - when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> { - event.setStatus("PROCESSED"); - event.setLastError(null); - event.setNextRetryAt(null); - return 1; - }); - if ("CN".equals(target)) { - when(firstlegEventClient.deliver(event)) - .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn")) - .thenReturn("200"); - } else { - when(eventClient.deliver(event)) - .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas")) - .thenReturn("200"); - } - - dispatcher.dispatchOne("event-1"); - assertThat(event.getStatus()).isEqualTo("RETRY"); - assertThat(event.getNextRetryAt()).isNotNull(); - assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId="); - dispatcher.dispatchOne("event-1"); - - ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); - verify(attemptMapper, times(2)).updateById(attempts.capture()); - assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo) - .containsExactly(1, 2); - assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus) - .containsExactly("RETRY", "DELIVERED"); - assertThat(attempts.getAllValues().get(0).getErrorMessage()) - .contains("HTTP 503", "requestId=", "traceId="); - assertThat(event.getStatus()).isEqualTo("PROCESSED"); - assertThat(event.getLastError()).isNull(); - } - } - - private BridgeDeliveryAttempt updatedAttempt() { - ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); - verify(attemptMapper).updateById(captor.capture()); - return captor.getValue(); - } - - private double transportCount(String result) { - return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) - .tags("target_region", event.getTargetRegion(), - "event_type", event.getEventType(), "result", result) - .counter().count(); - } -} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java deleted file mode 100644 index 1dbf17567..000000000 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.baiyue.eventbridge.trace; - -import com.baiyue.common.web.CorrelationContext; -import com.baiyue.eventbridge.domain.BridgeInbox; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; - -import java.net.URI; -import java.net.http.HttpRequest; - -import static org.assertj.core.api.Assertions.assertThat; - -class EventBridgeTracePropagationTest { - - @AfterEach - void clearMdc() { - MDC.clear(); - } - - @Test - void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { - BridgeInbox event = new BridgeInbox(); - event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); - event.setCorrelationId("ORDER:customer-99:booking-123"); - event.setEventId("event-business-id"); - - try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { - String localSpan = MDC.get(CorrelationContext.SPAN_ID); - HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) - .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) - .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) - .header(CorrelationContext.TRACE_PARENT_HEADER, - "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); - HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); - - assertThat(MDC.get(CorrelationContext.TRACE_ID)) - .isEqualTo("550e8400e29b41d4a716446655440000"); - assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) - .contains("550e8400e29b41d4a716446655440000"); - assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) - .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); - assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) - .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") - .doesNotContain(localSpan); - } - - assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); - } - - @Test - void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { - MDC.put("existing", "preserved"); - BridgeInbox event = new BridgeInbox(); - event.setTraceId("JWT-or-business-id-value"); - - try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { - assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") - .isNotEqualTo(event.getTraceId()); - } - - assertThat(MDC.get("existing")).isEqualTo("preserved"); - assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); - } -} 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 4fcb7d5df..452319bb9 100644 --- a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java @@ -21,13 +21,14 @@ public class ExportProviderClient { @Resource private WarehouseExportProviderClient warehouse; @Resource private FirstlegExportProviderClient firstleg; @Resource private FinanceExportProviderClient finance; + @Resource private LastlegExportProviderClient lastleg; @Resource private MeterRegistry meters; @PostConstruct void initializeClients() { this.clients = Map.of("iam-service", iam, "order-service", order, "warehouse-service", warehouse, "firstleg-service", firstleg, - "finance-service", finance); + "finance-service", finance, "lastleg-service", lastleg); } public ApiResponse batch(String internalToken, String exportType, diff --git a/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java new file mode 100644 index 000000000..02e5bc552 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java @@ -0,0 +1,7 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="lastleg-service", contextId="lastlegExportProviderClient", + url="${baiyue.clients.lastleg.url:}", configuration=ExportProviderFeignConfiguration.class) +interface LastlegExportProviderClient 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 bec3b2a44..712f64d2e 100644 --- a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java +++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java @@ -11,7 +11,7 @@ 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", "finance-service"); + private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service", "lastleg-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-]+){2,}"); diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index a25401d95..2bf1f64fb 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -46,7 +46,11 @@ public class ExportTaskService { } validateParameters(type, request.parameters()); validateTypeParameters(type, request.parameters()); - String parametersJson = parametersJson(request.parameters()); + Map parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters()); + if ("TRIP_OPERATIONS_LIST".equals(type)) { + parameters.put("costVisible", user.permissions().contains("platform:fleet:trip:list")); + } + String parametersJson = parametersJson(parameters); long id = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); FileExportTask task = new FileExportTask(); @@ -92,6 +96,7 @@ public class ExportTaskService { public DownloadResource download(AuthenticatedUser user, long id) { FileExportTask task = ownedTask(user, id); + requireExportCostAccess(user, task); if (!"SUCCEEDED".equals(task.getStatus())) { throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期"); } @@ -103,6 +108,7 @@ public class ExportTaskService { public ExportTaskResponse retry(AuthenticatedUser user, long id) { FileExportTask task = ownedTask(user, id); + requireExportCostAccess(user, task); var definition = registry.require(task.getExportType()); if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用"); String permission = definition.getRequiredPermission(); @@ -128,6 +134,18 @@ public class ExportTaskService { return response(repository.find(id)); } + private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) { + if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return; + try { + if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false) + && !user.permissions().contains("platform:fleet:trip:list")) { + throw new BizException(ResultCode.FORBIDDEN, "缺少出车成本读取权限,请重新创建不含成本的导出任务"); + } + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.CONFLICT, "导出筛选快照无效,请重新创建任务"); + } + } + private FileExportTask ownedTask(AuthenticatedUser user, long id) { requirePlatform(user); FileExportTask task = repository.find(id); diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 4c5595c8c..1dc24570c 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 + lastlegExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 financeExportProviderClient: connect-timeout: 3000 read-timeout: 15000 @@ -203,4 +206,76 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + LAST_MILE_ORDER_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches + required-permission: platform:overseas-warehouse:last-mile-order:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_INVENTORY_BALANCE: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches + required-permission: platform:overseas-warehouse:inventory:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_INVENTORY_LEDGER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches + required-permission: platform:overseas-warehouse:inventory:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + OVERSEAS_QUOTE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches + required-permission: platform:overseas-master-data:quote:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + MONTHLY_EXCHANGE_RATE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches + required-permission: platform:overseas-master-data:exchange-rate:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + VEHICLE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/VEHICLE_LIST/batches + required-permission: platform:overseas-master-data:vehicle:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + FLEET_QUOTE_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches + required-permission: platform:fleet:quote:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + TRIP_OPERATIONS_LIST: + provider-service: lastleg-service + provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches + required-permission: platform:fleet:operations:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 management.endpoints.web.exposure.include: health,info,prometheus 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 fd86f9e01..4221bf5fb 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java @@ -25,6 +25,7 @@ class ExportProviderClientTest { @MockBean private WarehouseExportProviderClient warehouse; @MockBean private FirstlegExportProviderClient firstleg; @MockBean private FinanceExportProviderClient finance; + @MockBean private LastlegExportProviderClient lastleg; @Resource private ExportProviderClient client; @Configuration(proxyBeanMethods = false) @@ -48,6 +49,16 @@ class ExportProviderClientTest { verifyNoInteractions(iam,warehouse,firstleg,finance); } + @Test void routesLastlegExportsThroughTheBoundedInternalClient() { + var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L)); + when(lastleg.batch(anyString(),anyString(),any())).thenReturn(response); + var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("lastleg-service"); + var request=new ExportBatchRequest("9","0","PLATFORM","7",1,Map.of(),null,null,200); + assertSame(response,client.batch("token","LAST_MILE_ORDER_LIST",definition,request)); + verify(lastleg).batch("token","LAST_MILE_ORDER_LIST",request); + verifyNoInteractions(iam,order,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); diff --git a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java new file mode 100644 index 000000000..15c4872e9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class LastlegExportDefinitionContractTest { + @Test void localAndNacosDefinitionsKeepTheSameBoundedProviderAndPermissions() throws Exception { + Map local = definitions(Path.of("src/main/resources/application.yml")); + Map nacos = definitions(Path.of("../config/nacos/test/file-service.yml")); + Map expected = Map.ofEntries( + Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"), + Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"), + Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"), + Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"), + Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "platform:overseas-master-data:exchange-rate:export"), + Map.entry("VEHICLE_LIST", "platform:overseas-master-data:vehicle:export"), + Map.entry("FLEET_QUOTE_LIST", "platform:fleet:quote:export"), + Map.entry("TRIP_OPERATIONS_LIST", "platform:fleet:operations:export")); + for (var entry : expected.entrySet()) { + var definition = (Map) local.get(entry.getKey()); + assertNotNull(definition, entry.getKey()); + assertEquals(definition, nacos.get(entry.getKey())); + assertEquals("lastleg-service", definition.get("provider-service")); + assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path")); + assertEquals(entry.getValue(), definition.get("required-permission")); + assertEquals("PLATFORM", definition.get("app-scope")); + assertEquals(200, definition.get("batch-size")); + var controlled = new ExportDefinitionRegistry.Definition(); + controlled.setProviderService("lastleg-service"); + controlled.setProviderPath(String.valueOf(definition.get("provider-path"))); + controlled.setRequiredPermission(entry.getValue()); + controlled.setAppScope("PLATFORM"); + var registry = new ExportDefinitionRegistry(); + assertDoesNotThrow(() -> registry.setDefinitions(Map.of(entry.getKey(), controlled))); + } + } + + private Map definitions(Path path) throws Exception { + Object value = new Yaml().load(Files.readString(path)); + for (String key : new String[]{"baiyue", "file", "export", "definitions"}) value = ((Map) value).get(key); + return (Map) value; + } +} + diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index d715efdae..de2d60427 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class}) @@ -55,6 +56,32 @@ class ExportTaskServiceTest { assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); } + @Test + void operationExportDerivesCostVisibilityFromAuthenticatedPermissions() { + var definition = definition(); + definition.setRequiredPermission("platform:fleet:operations:export"); + when(registry.require("TRIP_OPERATIONS_LIST")).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> + new ExportStateService.CreationResult(invocation.getArgument(0), true)); + service.create(user(1L, Set.of("platform:fleet:operations:export")), + new CreateExportTaskRequest("TRIP_OPERATIONS_LIST", "XLSX", "出车记录", Map.of("costVisible", true))); + var captured = org.mockito.ArgumentCaptor.forClass(FileExportTask.class); + verify(states).createConstrained(captured.capture()); + assertTrue(captured.getValue().getParametersJson().contains("\"costVisible\":false")); + } + + @Test + void costPermissionCannotBeBypassedByDownloadAllOrRetry() { + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L); + task.setExportType("TRIP_OPERATIONS_LIST"); task.setParametersJson("{\"costVisible\":true}"); + when(repository.find(9L)).thenReturn(task); + var reader = user(1L, Set.of("platform:download:all", "platform:fleet:operations:export")); + assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.download(reader,9L)).getResultCode()); + assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.retry(reader,9L)).getResultCode()); + verifyNoInteractions(storage); + } + @Test void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() { when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 38d0741ce..4cc9b3c76 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,6 +9,8 @@ ## 模块入口 +表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。 + 事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md deleted file mode 100644 index 8965757f4..000000000 --- a/gateway-overseas/AGENTS.md +++ /dev/null @@ -1,37 +0,0 @@ -# Overseas Gateway 模块指引 - -> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。当前入口读 `../gateway/AGENTS.md`,认证读 `../auth/AGENTS.md` 与 `../../docs/统一认证与服务通信契约.md`;退役清理按 `../../docs/测试环境单机部署与配置契约.md` 核对实际引用与运行证据。不得恢复双 Token、专用安全投影或重新加入构建。 - -## 职责边界 - -- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 -- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 -- 历史路由与身份实现只用于追溯;现行认证按 `../../docs/统一认证与服务通信契约.md`。 - -## 快速索引 - -| 功能 | 入口 | -|---|---| -| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | -| Audience 校验 | `config/AudienceJwtValidator.java` | -| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | -| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | -| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本不一致立即拒绝,并取回当前角色/权限快照 | -| 安全配置项 | `config/GatewaySecurityProperties.java` | -| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 | -| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | -| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 | -| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg | -| 测试 | `src/test/java/com/baiyue/gateway/config/` | - -## 安全规则 - -- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 -- 网关必须清除外请求伪造的可信身份与授权头,仅传递验签并核对安全投影后生成的上下文;业务服务必须验证内部服务令牌。 -- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 -- merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。 - -## 修改与验证 - -- 本模块已退出聚合,不能从根使用 `mvn -pl gateway-overseas`;当前路由变更验证 `gateway` 及调用端,执行根 `scripts/gateway-route-contract.test.mjs`。 -- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 diff --git a/gateway-overseas/pom.xml b/gateway-overseas/pom.xml deleted file mode 100644 index 01f656726..000000000 --- a/gateway-overseas/pom.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - 4.0.0 - - com.baiyue.firstleg - baiyue-firstleg-platform - 1.0.0-SNAPSHOT - ../pom.xml - - gateway-overseas - gateway-overseas - - - - org.springdoc - springdoc-openapi-starter-webflux-ui - - - com.baiyue.shared - baiyue-common-core - ${project.version} - - - org.springframework.cloud - spring-cloud-starter-gateway - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-discovery - - - org.springframework.cloud - spring-cloud-starter-loadbalancer - - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-config - - - com.alibaba.cloud - spring-cloud-starter-alibaba-sentinel - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.boot - spring-boot-starter-test - test - - - io.projectreactor - reactor-test - test - - - org.springframework.bootspring-boot-maven-plugin - diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java deleted file mode 100644 index 8514ad509..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.gateway; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * API 网关启动入口,承载统一路由、认证校验和可信身份头转发。 - */ -@SpringBootApplication -public class BaiyueGatewayOverseasApplication { - public static void main(String[] args) { - SpringApplication.run(BaiyueGatewayOverseasApplication.class, args); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java deleted file mode 100644 index 5dae7304a..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.ReactiveAuthorizationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.web.server.authorization.AuthorizationContext; -import reactor.core.publisher.Mono; - -/** - * 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。 - */ -final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager { - private final String expectedAppCode; - - AppScopeAuthorizationManager(String expectedAppCode) { - this.expectedAppCode = expectedAppCode; - } - - @Override - public Mono check(Mono authentication, - AuthorizationContext context) { - return authentication.filter(Authentication::isAuthenticated) - .map(this::hasExpectedScope).map(AuthorizationDecision::new) - .defaultIfEmpty(new AuthorizationDecision(false)); - } - - private boolean hasExpectedScope(Authentication authentication) { - return authentication instanceof JwtAuthenticationToken jwt - && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java deleted file mode 100644 index 0305a7498..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.jwt.Jwt; - -final class AudienceJwtValidator implements OAuth2TokenValidator { - private final String requiredAudience; - - AudienceJwtValidator(String requiredAudience) { - this.requiredAudience = requiredAudience; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - if (jwt.getAudience().contains(requiredAudience)) { - return OAuth2TokenValidatorResult.success(); - } - return OAuth2TokenValidatorResult.failure(new OAuth2Error( - "invalid_token", "JWT audience 不允许访问海外 API", null)); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java deleted file mode 100644 index a23d519e5..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.baiyue.gateway.config; - -import com.baiyue.gateway.filter.CorrelationIdWebFilter; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; -import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; -import org.springframework.security.web.server.SecurityWebFilterChain; -import reactor.core.publisher.Mono; - -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 - */ -@Configuration -@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, - OverseasSecurityProjectionProperties.class}) -public class GatewaySecurityConfig { - @Bean - SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, - GatewaySecurityProperties properties, - ObjectMapper objectMapper) { - var exchanges = http.authorizeExchange(); - properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); - exchanges.pathMatchers("/api/merchant/**") - .access(new AppScopeAuthorizationManager("MERCHANT")); - exchanges.pathMatchers("/api/platform/**") - .access(new AppScopeAuthorizationManager("PLATFORM")); - exchanges.anyExchange().authenticated(); - return http.csrf(ServerHttpSecurity.CsrfSpec::disable) - .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) - .formLogin(ServerHttpSecurity.FormLoginSpec::disable) - .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }) - .authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper, - HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效"))) - .exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) -> - writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源"))) - .build(); - } - - @Bean - ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { - NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder - .withJwkSetUri(properties.jwkSetUri()) - .jwsAlgorithms(algorithms -> { - algorithms.add(SignatureAlgorithm.RS256); - }) - .build(); - decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( - JwtValidators.createDefaultWithIssuer(properties.issuer()), - new AudienceJwtValidator(properties.audience()), - new RequiredClaimsJwtValidator())); - return decoder; - } - - public static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, - ObjectMapper mapper, HttpStatus status, String code, String message) { - exchange.getResponse().setStatusCode(status); - exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); - String requestId = CorrelationIdWebFilter.ensureRequestId(exchange); - String traceId = CorrelationIdWebFilter.ensureTraceId(exchange); - exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId); - exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId); - Map body = new LinkedHashMap<>(); - body.put("code", code); - body.put("message", message); - body.put("data", null); - body.put("requestId", requestId); - body.put("traceId", traceId); - body.put("timestamp", Instant.now().toString()); - try { - byte[] bytes = mapper.writeValueAsBytes(body); - return exchange.getResponse().writeWith( - Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); - } catch (Exception exception) { - return exchange.getResponse().setComplete(); - } - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java deleted file mode 100644 index 3dab56ba2..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.util.List; - -/** - * 海外网关安全配置,只保存验签所需的签发方和公钥集地址。 - */ -@ConfigurationProperties(prefix = "baiyue.security") -public record GatewaySecurityProperties(String jwkSetUri, - String issuer, - String audience, - List permitPaths) { - public GatewaySecurityProperties { - if (jwkSetUri == null || jwkSetUri.isBlank()) { - throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI"); - } - if (issuer == null || issuer.isBlank()) { - throw new IllegalArgumentException("必须配置 JWT_ISSUER"); - } - if (audience == null || audience.isBlank()) { - throw new IllegalArgumentException("必须配置 JWT_AUDIENCE"); - } - permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java deleted file mode 100644 index e488beaa9..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -@ConfigurationProperties(prefix = "baiyue.security.overseas-projection") -public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { - public OverseasSecurityProjectionProperties { - if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); - if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { - throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); - } - timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout; - serviceUrl = serviceUrl.replaceAll("/+$", ""); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java deleted file mode 100644 index e2e40b47e..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */ -@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation") -public record PdaSessionValidationProperties(String authUrl, - String internalServiceToken, - Duration timeout) { - public PdaSessionValidationProperties { - if (authUrl == null || authUrl.isBlank()) { - throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL"); - } - if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { - throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); - } - timeout = timeout == null ? Duration.ofSeconds(2) : timeout; - if (timeout.isZero() || timeout.isNegative()) { - throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0"); - } - authUrl = authUrl.replaceAll("/+$", ""); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java deleted file mode 100644 index 4135c50d0..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.baiyue.gateway.config; - -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.List; -import java.util.Locale; - -/** Validates the identity and authorization claims required by overseas APIs. */ -final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { - private static final List REQUIRED_CLAIMS = List.of( - "sub", "tenant_id", "app_code", "token_version", "permission_version"); - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - for (String claim : REQUIRED_CLAIMS) { - Object value = jwt.getClaims().get(claim); - if (value == null || value.toString().isBlank()) { - return OAuth2TokenValidatorResult.failure(new OAuth2Error( - "invalid_token", "JWT 缺少必要声明: " + claim, null)); - } - } - Long subject = longValue(jwt.getSubject()); - Long tenantId = longValue(jwt.getClaims().get("tenant_id")); - Long permissionVersion = longValue(jwt.getClaims().get("permission_version")); - Long tokenVersion = longValue(jwt.getClaims().get("token_version")); - String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT); - if (subject == null || subject <= 0) { - return failure("JWT sub 必须是正整数用户 ID"); - } - if (tenantId == null || tenantId < 0) { - return failure("JWT tenant_id 必须是非负整数客户范围"); - } - if (permissionVersion == null || permissionVersion < 0) { - return failure("JWT permission_version 必须是非负整数"); - } - if (tokenVersion == null || tokenVersion < 0) { - return failure("JWT token_version 必须是非负整数"); - } - if (!("PLATFORM".equals(appCode) && tenantId == 0L) - && !("MERCHANT".equals(appCode) && tenantId > 0L)) { - return failure("JWT app_code 与 tenant_id 访问域不一致"); - } - return OAuth2TokenValidatorResult.success(); - } - - private static Long longValue(Object value) { - if (value instanceof Number number) return number.longValue(); - if (value == null) return null; - try { - return Long.valueOf(value.toString()); - } catch (NumberFormatException ignored) { - return null; - } - } - - private static OAuth2TokenValidatorResult failure(String message) { - return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null)); - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java deleted file mode 100644 index f25941cec..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.baiyue.gateway.filter; - -import org.springframework.core.Ordered; -import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilter; -import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; - -import java.util.UUID; - -/** Ensures overseas security failures carry the same correlation contract as normal API responses. */ -@Component -public class CorrelationIdWebFilter implements WebFilter, Ordered { - public static final String REQUEST_HEADER = "X-Request-Id"; - public static final String TRACE_HEADER = "X-Trace-Id"; - private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; - private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; - - @Override - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange)); - exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange)); - return chain.filter(exchange); - } - - public static String ensureRequestId(ServerWebExchange exchange) { - return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE, - ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER)); - } - - public static String ensureTraceId(ServerWebExchange exchange) { - return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE, - ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER)); - } - - private static String accepted(HttpHeaders headers, String name) { - String incoming = headers.getFirst(name); - return valid(incoming) ? incoming : UUID.randomUUID().toString(); - } - - private static boolean valid(String value) { - return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128 - && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); - } - - @Override - public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE; - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java deleted file mode 100644 index 5adcb9b12..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; -import java.util.LinkedHashSet; -import java.util.Set; - -@Component -public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { - private static final String PATH = "/internal/lastleg/security/v1/validate"; - private final WebClient client; - private final OverseasSecurityProjectionProperties properties; - - public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) { - this.client = builder.build(); - this.properties = properties; - } - - @Override - public Mono validate(long userId, long tenantId, long permissionVersion) { - return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) - .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tenantId, permissionVersion)) - .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { - JsonNode data = body == null ? null : body.path("data"); - if (data == null || data.isMissingNode() || !data.has("valid")) { - return Mono.error(new IllegalStateException("海外安全投影响应无效")); - } - return Mono.just(new Result(data.path("valid").asBoolean(false), - data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null), - strings(data.path("roles")), strings(data.path("authorities")))); - }); - } - - private static Set strings(JsonNode values) { - if (values == null || !values.isArray()) return Set.of(); - LinkedHashSet result = new LinkedHashSet<>(); - values.forEach(value -> { - String text = value.asText(""); - if (!text.isBlank()) result.add(text); - }); - return Set.copyOf(result); - } - - private record Request(long userId, long tenantId, long permissionVersion) { } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java deleted file mode 100644 index 7f9bd9a97..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.baiyue.gateway.config.PdaSessionValidationProperties; -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */ -@Component -public class HttpPdaSessionValidationClient implements PdaSessionValidationClient { - private static final String PATH = "/internal/auth/accounts/overseas-session/validate"; - private final WebClient webClient; - private final PdaSessionValidationProperties properties; - - public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) { - this.webClient = builder.build(); - this.properties = properties; - } - - @Override - public Mono validate(long userId, int tokenVersion, long overseasWarehouseId) { - return webClient.post() - .uri(properties.authUrl() + PATH) - .contentType(MediaType.APPLICATION_JSON) - .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tokenVersion, overseasWarehouseId)) - .retrieve() - .bodyToMono(JsonNode.class) - .timeout(properties.timeout()) - .flatMap(this::decode); - } - - private Mono decode(JsonNode envelope) { - JsonNode data = envelope == null ? null : envelope.path("data"); - if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) { - return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid")); - } - boolean valid = data.path("valid").asBoolean(false); - String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null); - return Mono.just(new Result(valid, reason)); - } - - private record Request(long userId, int tokenVersion, long overseasWarehouseId) { } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java deleted file mode 100644 index 4d2de7857..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.baiyue.gateway.config.GatewaySecurityConfig; -import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.core.Ordered; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import java.util.Collection; -import java.util.stream.Collectors; - -@Component -public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { - private final OverseasSecurityValidationClient client; - private final ObjectMapper objectMapper; - private final OverseasSecurityProjectionProperties properties; - - public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper, - OverseasSecurityProjectionProperties properties) { - this.client = client; - this.objectMapper = objectMapper; - this.properties = properties; - } - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); - return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) - .ofType(JwtAuthenticationToken.class) - .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) - .flatMap(authentication -> validate(exchange, chain, authentication)) - .onErrorResume(SecurityProjectionFailure.class, - failure -> write(exchange, failure.code, failure.getMessage())) - .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); - } - - private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, - JwtAuthenticationToken authentication) { - Long userId = number(authentication.getToken().getSubject()); - Long tenantId = number(authentication.getToken().getClaim("tenant_id")); - Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); - if (userId == null || tenantId == null || permissionVersion == null) { - return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); - } - return client.validate(userId, tenantId, permissionVersion) - .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) - .flatMap(result -> { - if (result.valid()) { - var request = exchange.getRequest().mutate() - .header("X-Overseas-Roles", join(result.roles())) - .header("X-Overseas-Authorities", join(result.authorities())) - .header("X-Internal-Service-Token", properties.internalServiceToken()) - .build(); - return chain.filter(exchange.mutate().request(request).build()); - } - if ("USER_DISABLED".equals(result.failureReason())) { - return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); - } - return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); - }); - } - - private Mono write(ServerWebExchange exchange, String code, String message) { - return GatewaySecurityConfig.writeError(exchange, objectMapper, - "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); - } - - private static Long number(Object value) { - if (value instanceof Number number) return number.longValue(); - try { return value == null ? null : Long.valueOf(value.toString()); } - catch (NumberFormatException exception) { return null; } - } - - private static String join(Collection values) { - return values.stream().sorted().collect(Collectors.joining(",")); - } - - @Override public int getOrder() { return -95; } - - private static final class SecurityProjectionFailure extends RuntimeException { - private final String code; - private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java deleted file mode 100644 index a7ae9b3cd..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.gateway.filter; - -import reactor.core.publisher.Mono; -import java.util.Set; - -public interface OverseasSecurityValidationClient { - Mono validate(long userId, long tenantId, long permissionVersion); - record Result(boolean valid, String failureReason, Set roles, Set authorities) { - public Result { - roles = roles == null ? Set.of() : Set.copyOf(roles); - authorities = authorities == null ? Set.of() : Set.copyOf(authorities); - } - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java deleted file mode 100644 index b40726567..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.gateway.filter; - -import reactor.core.publisher.Mono; - -/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */ -public interface PdaSessionValidationClient { - Mono validate(long userId, int tokenVersion, long overseasWarehouseId); - - record Result(boolean valid, String failureReason) { } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java deleted file mode 100644 index 4e94ba57b..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.baiyue.gateway.config.GatewaySecurityConfig; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.core.Ordered; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */ -@Component -public class PdaSessionValidationFilter implements GlobalFilter, Ordered { - private static final String PDA_PREFIX = "/api/pda/"; - private final PdaSessionValidationClient client; - private final ObjectMapper objectMapper; - - public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) { - this.client = client; - this.objectMapper = objectMapper; - } - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) { - return chain.filter(exchange); - } - return exchange.getPrincipal().cast(Authentication.class) - .filter(Authentication::isAuthenticated) - .ofType(JwtAuthenticationToken.class) - .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效"))) - .flatMap(authentication -> validate(exchange, chain, authentication)) - .onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage())) - .onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用")); - } - - private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, - JwtAuthenticationToken authentication) { - Long userId = longValue(authentication.getToken().getSubject()); - Integer tokenVersion = intClaim(authentication, "token_version"); - Long warehouseId = longClaim(authentication, "overseas_warehouse_id"); - if (userId == null || tokenVersion == null) { - return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失")); - } - if (warehouseId == null) { - return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库")); - } - return client.validate(userId, tokenVersion, warehouseId) - .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用"))) - .flatMap(result -> { - if (result.valid()) return chain.filter(exchange); - if ("WAREHOUSE_UNBOUND".equals(result.failureReason()) - || "WAREHOUSE_MISMATCH".equals(result.failureReason())) { - return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化")); - } - return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效")); - }); - } - - private Mono write(ServerWebExchange exchange, String code, String message) { - HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED; - return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message); - } - - private static Integer intClaim(JwtAuthenticationToken authentication, String name) { - Object value = authentication.getToken().getClaims().get(name); - if (value instanceof Number number) return number.intValue(); - try { return value == null ? null : Integer.valueOf(value.toString()); } - catch (NumberFormatException ignored) { return null; } - } - - private static Long longClaim(JwtAuthenticationToken authentication, String name) { - return longValue(authentication.getToken().getClaims().get(name)); - } - - private static Long longValue(Object value) { - if (value instanceof Number number) return number.longValue(); - try { return value == null ? null : Long.valueOf(value.toString()); } - catch (NumberFormatException ignored) { return null; } - } - - @Override public int getOrder() { return -90; } - - private static final class InvalidPdaSession extends RuntimeException { - private final String code; - private InvalidPdaSession(String code, String message) { super(message); this.code = code; } - } -} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java deleted file mode 100644 index f0c00dc91..000000000 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.baiyue.gateway.filter; - -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.core.Ordered; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -import java.util.List; - -/** - * 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。 - */ -@Component -public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { - private static final List TRUSTED_HEADERS = List.of( - "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id", - "X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities", - "X-Internal-Service-Token" - ); - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - return exchange.getPrincipal() - .cast(Authentication.class) - .defaultIfEmpty(new AnonymousAuthentication()) - .flatMap(authentication -> { - var requestBuilder = exchange.getRequest().mutate(); - requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove)); - - if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) { - put(requestBuilder, "X-User-Id", jwt.getToken().getSubject()); - put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id")); - put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id")); - put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id")); - put(requestBuilder, "X-App-Code", claim(jwt, "app_code")); - put(requestBuilder, "X-Client-Type", claim(jwt, "client_type")); - } - return chain.filter(exchange.mutate().request(requestBuilder.build()).build()); - }); - } - - private static String claim(JwtAuthenticationToken jwt, String name) { - Object value = jwt.getToken().getClaims().get(name); - return value == null ? null : value.toString(); - } - - private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder, - String name, - String value) { - if (value != null && !value.isBlank()) { - builder.header(name, value); - } - } - - @Override - public int getOrder() { - return -100; - } - - private static final class AnonymousAuthentication implements Authentication { - @Override public java.util.Collection getAuthorities() { return List.of(); } - @Override public Object getCredentials() { return ""; } - @Override public Object getDetails() { return null; } - @Override public Object getPrincipal() { return "anonymous"; } - @Override public boolean isAuthenticated() { return false; } - @Override public void setAuthenticated(boolean isAuthenticated) { } - @Override public String getName() { return "anonymous"; } - } -} diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml deleted file mode 100644 index 0cd871367..000000000 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ /dev/null @@ -1,72 +0,0 @@ -spring: - config: - activate: - on-profile: overseas - cloud: - gateway: - discovery: - locator: - enabled: false - routes: - - id: lastleg-service - uri: lb://lastleg-service - predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"] - - id: warehouse-overseas-service - uri: lb://warehouse-overseas-service - predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] - - id: file-service - uri: lb://file-service - predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"] - - id: print-service - uri: lb://print-service - predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"] - - id: lastleg-openapi - uri: lb://lastleg-service - predicates: [Path=/lastleg-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: warehouse-openapi - uri: lb://warehouse-overseas-service - predicates: [Path=/warehouse-overseas-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: file-openapi - uri: lb://file-service - predicates: [Path=/file-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: print-openapi - uri: lb://print-service - predicates: [Path=/print-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: audit-openapi - uri: lb://audit-service - predicates: [Path=/audit-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: integration-openapi - uri: lb://integration-service - predicates: [Path=/integration-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: event-bridge-openapi - uri: lb://event-bridge-service - predicates: [Path=/event-bridge-service/v3/api-docs/**] - filters: [StripPrefix=1] - -baiyue: - deployment: - region-code: ${REGION_CODE:OVERSEAS} - cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - -springdoc: - swagger-ui: - urls: - - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } - - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } - - { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda } - - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } - - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } - - { name: file-platform, url: /file-service/v3/api-docs/platform } - - { name: file-merchant, url: /file-service/v3/api-docs/merchant } - - { name: print-platform, url: /print-service/v3/api-docs/platform } - - { name: print-merchant, url: /print-service/v3/api-docs/merchant } - - { name: audit, url: /audit-service/v3/api-docs } - - { name: integration, url: /integration-service/v3/api-docs } - - { name: event-bridge, url: /event-bridge-service/v3/api-docs } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml deleted file mode 100644 index fca38239b..000000000 --- a/gateway-overseas/src/main/resources/application.yml +++ /dev/null @@ -1,78 +0,0 @@ -server: - port: ${GATEWAY_PORT:9300} - -spring: - data: - redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} - application: - name: gateway-overseas - profiles: - default: overseas - autoconfigure: - exclude: - - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration - config: - import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - cloud: - nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - enabled: ${NACOS_DISCOVERY_ENABLED:true} - register-enabled: ${NACOS_REGISTER_ENABLED:true} - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - enabled: ${NACOS_CONFIG_ENABLED:true} - namespace: ${NACOS_NAMESPACE:public} - gateway: - discovery: - locator: - enabled: false - -baiyue: - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} - permit-paths: - - /actuator/health - - /swagger-ui/** - - /webjars/** - - /v3/api-docs/** - - /lastleg-service/v3/api-docs/** - - /warehouse-overseas-service/v3/api-docs/** - - /file-service/v3/api-docs/** - - /print-service/v3/api-docs/** - - /audit-service/v3/api-docs/** - - /integration-service/v3/api-docs/** - - /event-bridge-service/v3/api-docs/** - -management: - endpoints: - web: - exposure: - include: health,info,prometheus - endpoint: - health: - probes: - enabled: true - -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java deleted file mode 100644 index 21e2371a7..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.baiyue.gateway.config; - -import org.junit.jupiter.api.Test; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import java.time.Instant; - -class AppScopeAuthorizationManagerTest { - @Test - void acceptsMatchingApplicationScope() { - var manager = new AppScopeAuthorizationManager("MERCHANT"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) - .expectNextMatches(decision -> decision.isGranted()) - .verifyComplete(); - } - - @Test - void rejectsDifferentApplicationScope() { - var manager = new AppScopeAuthorizationManager("PLATFORM"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) - .expectNextMatches(decision -> !decision.isGranted()) - .verifyComplete(); - } - - @Test - void rejectsMissingApplicationScope() { - var manager = new AppScopeAuthorizationManager("PLATFORM"); - Jwt jwt = Jwt.withTokenValue("token") - .header("alg", "none") - .subject("1") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(60)) - .build(); - StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken( - jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null)) - .expectNextMatches(decision -> !decision.isGranted()) - .verifyComplete(); - } - - private JwtAuthenticationToken authentication(String appCode) { - Jwt jwt = Jwt.withTokenValue("token") - .header("alg", "none") - .subject("1") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(60)) - .claim("app_code", appCode) - .build(); - return new JwtAuthenticationToken(jwt, - java.util.List.of(new SimpleGrantedAuthority("authenticated"))); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java deleted file mode 100644 index 474c2ec6f..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.gateway.config; - -import org.junit.jupiter.api.Test; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class AudienceJwtValidatorTest { - private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api"); - - @Test - void acceptsTokenForLastlegApi() { - assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse(); - } - - @Test - void rejectsTokenForAnotherApi() { - assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue(); - } - - private Jwt jwt(List audiences) { - return Jwt.withTokenValue("token") - .header("alg", "RS256") - .subject("1001") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(300)) - .audience(audiences) - .build(); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java deleted file mode 100644 index 6b1cea992..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.baiyue.gateway.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.web.server.MockServerWebExchange; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - -class GatewaySecurityErrorContractTest { - @Test - void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception { - var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test") - .header("X-Request-Id", "request-1234567890") - .header("X-Trace-Id", "trace-123456789012") - .build()); - - GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED, - "A0401", "未登录或令牌已失效").block(); - - var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block()); - assertEquals("request-1234567890", body.path("requestId").asText()); - assertEquals("trace-123456789012", body.path("traceId").asText()); - assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id")); - assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id")); - assertFalse(body.path("timestamp").asText().isBlank()); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java deleted file mode 100644 index 70757edf3..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.baiyue.gateway.config; - -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasLastlegRouteContractTest { - @Test - void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException { - try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { - assertThat(input).isNotNull(); - String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); - int routeStart = yaml.indexOf("- id: lastleg-service"); - int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart); - assertThat(routeStart).isGreaterThanOrEqualTo(0); - assertThat(routeEnd).isGreaterThan(routeStart); - String route = yaml.substring(routeStart, routeEnd); - - assertThat(route).contains("uri: lb://lastleg-service") - .contains("/api/platform/v1/last-leg/**") - .contains("/api/merchant/v1/last-leg/**") - .contains("/api/pda/v1/last-leg/**") - .doesNotContain("StripPrefix", "RewritePath"); - } - } - - @Test - void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException { - try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { - assertThat(input).isNotNull(); - String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); - assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda") - .contains("Path=/lastleg-service/v3/api-docs/**") - .doesNotContain("event-bridge-service/v3/api-docs/pda"); - } - } - - @Test - void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() throws IOException { - try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { - assertThat(input).isNotNull(); - String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); - assertThat(yaml).contains("/api/pda/v1/last-leg/**"); - for (String path : new String[] { - "/api/pda/v1/last-leg/dispatches/DSP-1", - "/api/pda/v1/last-leg/dispatches/DSP-1/depart", - "/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete" - }) { - assertThat(path).startsWith("/api/pda/v1/last-leg/"); - } - } - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java deleted file mode 100644 index a5964dbe4..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.baiyue.gateway.config; - -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 OverseasSecurityValidationDeploymentContractTest { - private static final String HEADER_SIZE = - "max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}"; - - @Test - void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception { - String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); - String lastlegApplication = read("lastleg-service/src/main/resources/application.yml"); - String localGateway = read("config/nacos/local/gateway-overseas.yml"); - String localLastleg = read("config/nacos/local/lastleg-service.yml"); - String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml"); - String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml"); - String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml"); - String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml"); - - for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) { - assertThat(source) - .contains("${JWT_ISSUER:auth-service}") - .contains("${JWT_AUDIENCE:lastleg-api}") - .contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}"); - } - assertThat(gatewayApplication).doesNotContain("auth.example.com"); - assertThat(localGateway) - .contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}") - .contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}"); - assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}"); - assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}"); - assertThat(remoteGateway + remoteLastleg + remoteWarehouse) - .doesNotContain("127.0.0.1", "root", "123456"); - assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}"); - assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}"); - assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}"); - } - - @Test - void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { - String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); - String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); - String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); - String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); - String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); - String compose = read("deploy/docker-compose.overseas.yml"); - - assertThat(client).contains("/internal/lastleg/security/v1/validate") - .contains("new Request(userId, tenantId, permissionVersion)") - .doesNotContain("new Request(userId, tenantId, tokenVersion") - .doesNotContain("/internal/overseas-security/"); - for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { - assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); - } - for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { - assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", - "/internal/lastleg/security/v1/events"); - } - assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) - .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", - "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); - } - - @Test - void overseasAuthorizationSnapshotHeadersFitEveryServletService() throws Exception { - assertThat(read("config/nacos/overseas/baiyue-common.yml")).contains(HEADER_SIZE); - assertThat(read("lastleg-service/src/main/resources/application.yml")).contains(HEADER_SIZE); - assertThat(read("warehouse-overseas-service/src/main/resources/application.yml")).contains(HEADER_SIZE); - assertThat(read("deploy/docker-compose.overseas.yml")) - .contains("SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java deleted file mode 100644 index 2b67d9ffd..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.baiyue.gateway.config; - -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 PdaSessionValidationDeploymentContractTest { - @Test - void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception { - String application = read("gateway-overseas/src/main/resources/application.yml"); - String nacos = read("config/nacos/overseas/baiyue-common.yml"); - String compose = read("deploy/docker-compose.overseas.yml"); - String deployServices = read("deploy/scripts/deploy-services.sh"); - String deployBuiltService = read("deploy/scripts/deploy-built-service.sh"); - String jenkins = read("Jenkinsfile"); - - for (String source : new String[]{application, nacos, compose}) { - assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN", - "PDA_SESSION_VALIDATION_TIMEOUT"); - } - assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); - assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); - assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'", - "'iam': 'SERVICE_IAM'"); - assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate"); - assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml")) - .doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate"); - assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java")) - .contains("new Request(userId, tokenVersion, overseasWarehouseId)", - ".header(\"X-Internal-Service-Token\"") - .doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java deleted file mode 100644 index 81b21ddb1..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.baiyue.gateway.config; - -import org.junit.jupiter.api.Test; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class RequiredClaimsJwtValidatorTest { - private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator(); - - @Test - void acceptsCompleteIdentityClaims() { - assertThat(validator.validate(jwt(true)).hasErrors()).isFalse(); - } - - @Test - void rejectsMissingPermissionVersion() { - assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); - } - - @Test - void rejectsMissingAccountTokenVersion() { - Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "0").claim("app_code", "PLATFORM") - .claim("permission_version", 3L) - .build(); - assertThat(validator.validate(value).hasErrors()).isTrue(); - } - - @Test - void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() { - Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) - .claim("permission_version", 3L) - .build(); - assertThat(validator.validate(value).hasErrors()).isFalse(); - } - - @Test - void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() { - assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse(); - } - - @Test - void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() { - assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors()) - .isFalse(); - } - - @Test - void rejectsApplicationAndCustomerScopeMismatch() { - assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors()) - .isTrue(); - assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors()) - .isTrue(); - } - - @Test - void rejectsMalformedIdentityAndPermissionVersion() { - Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L) - .claim("token_version", 1) - .claim("authorities", List.of()).build(); - assertThat(validator.validate(value).hasErrors()).isTrue(); - } - - private Jwt jwt(boolean includePermissionVersion) { - return jwt("0", "PLATFORM", - List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion); - } - - private Jwt jwt(String tenantId, String appCode, List authorities, - boolean includePermissionVersion) { - Jwt.Builder builder = Jwt.withTokenValue("token") - .header("alg", "RS256") - .subject("1001") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", tenantId) - .claim("app_code", appCode) - .claim("token_version", 1) - .claim("authorities", authorities); - if (includePermissionVersion) { - builder.claim("permission_version", 3L); - } - return builder.build(); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java deleted file mode 100644 index 4c1847ecb..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import java.time.Instant; -import java.time.Duration; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -class OverseasSecurityProjectionFilterTest { - private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); - private final OverseasSecurityProjectionProperties properties = - new OverseasSecurityProjectionProperties("http://lastleg", - "gateway-internal-test-token-32-bytes", Duration.ofSeconds(2)); - private final OverseasSecurityProjectionFilter filter = - new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties); - - @Test void disabledUserFailsWithA0401() { - when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of()))); - var exchange = exchange("/api/platform/v1/last-leg/orders"); - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(body(exchange)).contains("A0401"); - } - - @Test void disabledTenantAndStalePermissionFailWithA0403() { - for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { - when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of()))); - var exchange = exchange("/api/merchant/v1/last-leg/orders"); - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - assertThat(body(exchange)).contains("A0403"); - } - } - - @Test void projectionUnavailableFailsClosed() { - when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); - var exchange = exchange("/api/platform/v1/last-leg/orders"); - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { - when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"), - Set.of("platform:overseas-warehouse:last-mile-order:list")))); - AtomicBoolean routed = new AtomicBoolean(); - var accepted = exchange("/api/platform/v1/last-leg/orders"); - StepVerifier.create(filter.filter(accepted, routedExchange -> { - routed.set(true); - assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Internal-Service-Token")) - .isEqualTo("gateway-secret"); - assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities")) - .isEqualTo("platform:overseas-warehouse:last-mile-order:list"); - return Mono.empty(); - })).verifyComplete(); - assertThat(routed).isTrue(); - - var isolatedClient = mock(OverseasSecurityValidationClient.class); - var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties); - StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); - verifyNoInteractions(isolatedClient); - } - - private static ServerWebExchange exchange(String path) { - Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); - return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, - List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); - } - - private static String body(ServerWebExchange exchange) { - return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) - .getBodyAsString().block(); - } -} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java deleted file mode 100644 index 73155dbd8..000000000 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.baiyue.gateway.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -class PdaSessionValidationFilterTest { - private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class); - private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper()); - - @Test - void oldTokenIsRejectedImmediatelyAfterWarehouseChange() { - when(client.validate(42L, 7, 900L)).thenReturn(Mono.just( - new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH"))); - var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L)); - - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(body(exchange)).contains("A0401", "令牌已失效"); - } - - @Test - void newlyIssuedTokenWithCurrentWarehousePasses() { - when(client.validate(42L, 8, 901L)).thenReturn(Mono.just( - new PdaSessionValidationClient.Result(true, null))); - var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); - AtomicBoolean routed = new AtomicBoolean(); - - StepVerifier.create(filter.filter(exchange, ignored -> { - routed.set(true); - return Mono.empty(); - })).verifyComplete(); - - assertThat(routed).isTrue(); - assertThat(exchange.getResponse().getStatusCode()).isNull(); - } - - @Test - void unboundWarehouseIsDeniedWithoutCallingAuth() { - var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null)); - - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - assertThat(body(exchange)).contains("A0403", "未绑定仓库"); - verifyNoInteractions(client); - } - - @Test - void authUnavailableFailsClosed() { - when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout"))); - var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); - - StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); - - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(body(exchange)).contains("A0401", "校验不可用"); - } - - @Test - void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() { - for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) { - var exchange = exchange(path, claims(7, 900L)); - AtomicBoolean routed = new AtomicBoolean(); - StepVerifier.create(filter.filter(exchange, ignored -> { - routed.set(true); - return Mono.empty(); - })).verifyComplete(); - assertThat(routed).isTrue(); - } - verifyNoInteractions(client); - } - - private static ServerWebExchange exchange(String path, Map claims) { - Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server") - .header("alg", "RS256") - .subject("42") - .issuedAt(Instant.now().minusSeconds(5)) - .expiresAt(Instant.now().plusSeconds(300)); - claims.forEach(jwt::claim); - MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get(path).build()); - return exchange.mutate() - .principal(Mono.just(new JwtAuthenticationToken(jwt.build(), - List.of(new SimpleGrantedAuthority("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"))))) - .build(); - } - - private static Map claims(int tokenVersion, Long warehouseId) { - var claims = new java.util.LinkedHashMap(); - claims.put("token_version", tokenVersion); - if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId); - return claims; - } - - private static String body(ServerWebExchange exchange) { - return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) - .getBodyAsString().block(); - } -} diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index d7ad899ca..fc63cac7f 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -17,4 +17,4 @@ - 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。 - 订单与客户承载登录后 Admin/Client 入口;入库测量与计费接入到仓/测量/计费公开路由但保持内部测量接口隔离;国内货柜履约仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。 -- 国内货柜履约先读 `../../docs/头程到尾程/README.md` 和 `../../docs/国内货柜履约/README.md`;网关变更只服务当前维护入口已确认的模块,不以路由提前暴露未完成能力。开工门禁属于归档资料,只有历史追溯时读取。 +- 已知路由任务直接核对目标 Controller、公开访问域、权限、Gateway 配置和 OpenAPI;只有货柜业务范围或流程门禁不明时,才从 `../../docs/国内货柜履约/README.md` 或 `../../docs/头程到尾程/README.md` 进入。归档开工门禁仅用于历史追溯,不以新增路由推定业务已完成。 diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 94758a195..fa4d963da 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -172,7 +172,8 @@ baiyue: allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} security: - jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + session-validation-uri: ${SESSION_VALIDATION_URI:http://127.0.0.1:9100/internal/auth/accounts/sessions/validate} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} 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 7378bee76..054ea3003 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -60,7 +60,7 @@ public class PlatformTenantController { }) public ApiResponse> page( @RequestParam(defaultValue = "1") @Min(1) long page, - @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(defaultValue = "20") @Min(1) @Max(1000) long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 66e01f4bf..0eee3defd 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -5,6 +5,16 @@ spring: active: ${SPRING_PROFILES_ACTIVE:test} application: name: iam-service + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${IAM_DB_USERNAME:root} + password: ${IAM_DB_PASSWORD:123456} + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} config: import: - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true @@ -36,4 +46,9 @@ baiyue: url: ${AUTH_SERVICE_URL:} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN} + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + session-mode: ${SESSION_MODE:redis} + token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 4efba3d74..e34bd6725 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -22,7 +22,7 @@ VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0), - (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,0), + (5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,0), (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,0), (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), @@ -41,6 +41,7 @@ VALUES (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,0), (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), @@ -65,6 +66,7 @@ VALUES (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,0), (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), @@ -284,6 +286,26 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +-- 司机与车队正式写权限仅授予平台超级管理员,供退货换标真实排车使用。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-master-data:driver:list', + 'platform:overseas-master-data:driver:create', + 'platform:overseas-master-data:driver:edit', + 'platform:overseas-master-data:driver:upload', + 'platform:overseas-master-data:driver:review', + 'platform:fleet:trip:list', + 'platform:fleet:trip:create', + 'platform:fleet:trip:assign', + 'platform:fleet:trip:edit', + 'platform:fleet:trip:dispatch-review', + 'platform:fleet:trip:loading-review') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + -- 客户默认管理员获得当前客户海外业务入口;其他客户角色按企业实际分工授权。 INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index e893dee83..28e223b91 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -93,7 +93,7 @@ paths: description: 分页查询商户企业客户,需要 `platform:customer:list` 权限;联系电话和邮箱仅返回脱敏摘要。 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: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 1000, 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]}} responses: 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 b2ed7fb78..33b1c9c2a 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -30,7 +30,7 @@ class PlatformTenantControllerContractTest { method.getAnnotation(PreAuthorize.class).value()); assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); - assertEquals(200, annotation(method, 1, Max.class).value()); + assertEquals(1000, annotation(method, 1, Max.class).value()); assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", annotation(method, 3, Pattern.class).regexp()); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index acb7a22f0..c7cdc23e9 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -43,6 +43,8 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-warehouse:inbound-order:smart-dispatch"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:discard:list", + "platform:overseas-warehouse:discard:approve", "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", @@ -75,7 +77,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(69).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(71).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", @@ -156,6 +158,12 @@ class OverseasPhase4PermissionSqlContractTest { // Vehicle care already declares this guard directly on its finance-submit facade. serviceCatalog.addAll(codes(repositoryFile( "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java"))); + // Cargo discard declares its approval guard in the service rather than OverseasPermissions. + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java"))); + assertThat(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java")) + .contains("user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)"); assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 7afcd57ab..957978036 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,18 +9,20 @@ ## 快速索引 +业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 + 拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 -先沿目标引用追踪;状态、权限、资源归属或接口边界仍不清楚时,用 `rg -n -F '关键词' docs/business-index.md` 定向读取。纯文案、局部重构及已知入口不扩读无关约束。 +沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。 -退货预报/签收/库存/合箱/出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程托盘快照:`inboundplan/`。细则按对应词查业务索引。 +退货链查 `returnreceipt/`;计划、自动托盘、柜级预览及来源托盘查 `inboundplan/`。 | 功能 | 首个入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` | -| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` | | 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` | diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index a02edfb28..72fbaa3bf 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -21,7 +21,7 @@ | 功能 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | -| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | | 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 0f2043c54..5255b933e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; @@ -50,6 +51,7 @@ import java.util.List; @RestController @Tag(name = "Tail-leg orders") public class LastlegOrderController { + @Resource private ReturnReceiptService returnReceiptService; @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @@ -165,7 +167,9 @@ public class LastlegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id) { - return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); + String scopedCustomerId = platformScope(user, customerId); + returnReceiptService.ensureTailCargo(scopedCustomerId, id); + return ApiResponse.success(orderService.detail(scopedCustomerId, id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review") @@ -257,20 +261,6 @@ public class LastlegOrderController { return ApiResponse.success(null); } - @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") - @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) - public ResponseEntity export( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @Valid LastlegOrderRequests.Query query) { - byte[] body = orderService.exportCsv(platformScope(user, customerId), query); - return ResponseEntity.ok() - .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") - .body(body); - } - @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "将单票尾程订单转入 FBA 目的仓", diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 4c1c7725b..49bb33ff4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -78,7 +78,18 @@ public final class DeliveryRequests { @NotBlank @Size(max=128) String returnNo, @NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks, @Size(max=1000) String remark, - @NotBlank @Size(max=255) String idempotencyKey) {} + @NotBlank @Size(max=255) String idempotencyKey, + @Size(max=128) String skuCode, + @jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType) { + public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo, + List cartonMarks, String remark, String idempotencyKey) { + this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null); + } + public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo, + List cartonMarks, String remark, String idempotencyKey, String skuCode) { + this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, null); + } + } public record WarehouseTransferOutbound( @NotBlank @Size(max=64) String transferNo, @NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds, @@ -102,9 +113,14 @@ public final class DeliveryRequests { } public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId, @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey, - @Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) { + @Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks, + @Size(max=128) String skuCode) { public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) { - this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of()); + this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of(), null); + } + public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, + String idempotencyKey, List cartonMarks) { + this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, null); } } public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 2fb6c2c51..d192e88fc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -41,7 +41,15 @@ public final class DeliveryResponses { public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, String skuCode, String productName, int packageCount, - BigDecimal weightKg, BigDecimal volumeCbm) {} + BigDecimal weightKg, BigDecimal volumeCbm, Long productQuantity) { + public DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, + String receivingAddress, String shipmentNo, String fbaTrackingNo, + String skuCode, String productName, int packageCount, + BigDecimal weightKg, BigDecimal volumeCbm) { + this(inboundCode, customerName, destinationWarehouseCode, receivingAddress, shipmentNo, + fbaTrackingNo, skuCode, productName, packageCount, weightKg, volumeCbm, null); + } + } public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency, BigDecimal amount, String remark, String reviewStatus, String sourceStatus, String voidStatus) {} @@ -65,7 +73,7 @@ public final class DeliveryResponses { String skuCode, String productName, long availablePackages, BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {} public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId, - String cartonMark, String fbaCartonMark, + String cartonMark, String palletCode, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, long totalQuantity, String status) {} public record WarehouseTransferOutboundCandidate(String customerId, String customerName, @@ -78,8 +86,7 @@ public final class DeliveryResponses { Long palletId, Long warehousePalletId, String palletNo, int packageCount, BigDecimal grossWeightKg, BigDecimal volumeCbm) {} - public record PdaTask(Long id, String taskNo, String status, String outboundNo, String outboundType, - LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt, String outboundType) {} public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, String skuCode, String inboundCode, long availablePackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 7e45cc8d8..c89c0e2eb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -114,6 +114,7 @@ public class DeliveryTransferService { @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnCartonItems; @Resource private InboundPlanPalletAllocationMapper planAllocations; @Resource private InboundPlanPalletMapper inboundPlanPallets; @Resource private WarehouseResourceGateway warehouseResources; @@ -485,46 +486,91 @@ public class DeliveryTransferService { public List returnOutboundCandidates(String customerId, String warehouseId) { if (!StringUtils.hasText(warehouseId)) return List.of(); return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream() + .filter(carton -> !"PARCEL".equals(carton.getSourceUnitType())) .map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(), - c.getCartonMark(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), + c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), value(c.getTotalQuantity()), c.getStatus())).toList(); } @Transactional public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId, DeliveryRequests.ReturnOutbound request) { + String requestHash = hash(request); OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); - if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); String warehouseId = request.overseasWarehouseId().trim(); List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); LocalDateTime now = LocalDateTime.now(); List locked = new ArrayList<>(); - for (String mark : marks) { + for (String mark : marks.stream().sorted().toList()) { ReturnInventoryCarton carton = returnCartons.lockMark(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) - || !"AVAILABLE".equals(carton.getStatus()) || !Objects.equals(request.returnNo().trim(), carton.getSourceReturnNo())) { + || !returnStock.matchesReturnSource(carton, request.returnNo())) { throw conflict("所选退货箱唛不可出库或不属于该退货单:" + mark); } locked.add(carton); } + existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); + for (ReturnInventoryCarton carton : locked) { + if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark()); + var contents = returnCartonItems.lockCarton(carton.getId()); + if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) + throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark()); + if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1 + || !request.skuCode().trim().equals(contents.get(0).getSkuCode()))) + throw conflict("单SKU出库只支持指定SKU的散件库存"); + if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType())) + throw conflict("已装箱库存请按箱唛出库"); + if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode())) + throw conflict("整托出库必须存在托盘绑定"); + } + if ("PALLET".equals(request.unitType())) { + String pallet = locked.get(0).getPalletCode(); + if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘"); + var all = returnCartons.listActivePallet(warehouseId, pallet); + if (all.size() != marks.size() || all.stream().anyMatch(c -> !customerId.equals(c.getCustomerId()) + || !marks.contains(c.getCartonMark()) || !"AVAILABLE".equals(c.getStatus()))) + throw conflict("整托出库必须选择该托盘全部可用箱,不可遗漏或包含其他客户库存"); + } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId())); outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); - outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0); + outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0); outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); for (ReturnInventoryCarton carton : locked) { OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); item.setCreatedAt(now); outboundPallets.insert(item); + item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); + item.setSkuCode(trim(request.skuCode())); + item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null); + item.setCreatedAt(now); outboundPallets.insert(item); + carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); } + DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim()); + delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim()); + delivery.setPackageCount(marks.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0); + delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "RETURN-DELIVERY")); + delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); + deliveryOrders.insert(delivery); OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); return taskResult(outbound, task); } + private DeliveryResponses.PdaTask replayReturnOutbound(String customerId, OutboundOrder existing, String requestHash) { + if (!"RETURN_OUTBOUND".equals(existing.getOutboundType()) || !requestHash.equals(existing.getRequestHash())) + throw conflict("相同幂等键不能用于不同的退货出库请求"); + return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + } + @Transactional(readOnly = true) public List warehouseTransferOutboundCandidates( String customerId, String warehouseId) { @@ -731,6 +777,11 @@ public class DeliveryTransferService { @Transactional public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) { + return pickResolved(warehouseId, operatorId, taskNo, request, false); + } + + private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo, + DeliveryRequests.PdaPick request, boolean palletScan) { OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), required(warehouseId, "PDA warehouse binding is required")); if (task == null) throw notFound("PDA task not found in the bound warehouse"); @@ -744,9 +795,11 @@ public class DeliveryTransferService { throw conflict("Idempotency key was reused with a different outbound pick request"); } OutboundOrder replayOutbound = requireOutbound(task); - if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())) { - OutboundPalletItem replayItem = outboundPallets.selectById(replay.getOutboundPalletItemId()); - requireReturnScan(replayOutbound, replayItem, request); + if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType()) || "RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType())) { + OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId()); + if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType()) + || (replayItem != null && replayItem.getWarehousePalletId() == null)) + requireReturnScan(replayOutbound, replayItem, request); } return taskResult(replayOutbound, task); } @@ -761,16 +814,14 @@ public class DeliveryTransferService { if (item == null || !customerId.equals(item.getCustomerId()) || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); - if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { + if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) + || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) { + if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan) + throw conflict("整托出库请扫描托盘码,不允许逐箱提交"); requireReturnScan(outbound, item, request); - if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱唛已完成拣货"); - ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks()); - requireReturnSource(outbound, item, carton); - if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks()); - long outboundPickId = IdWorker.getId(); - returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), - outbound.getOutboundNo(), outboundPickId); - OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库"); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId()); item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); @@ -934,7 +985,7 @@ public class DeliveryTransferService { if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { throw notFound("Outbound order not found in the bound warehouse"); } - if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) { + if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND", "RETURN_OUTBOUND").contains(outbound.getOutboundType())) { throw conflict("整托扫码仅适用于一件代发或换标出库"); } if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { @@ -947,6 +998,24 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { + var expectedMarks = palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount())) + .map(OutboundPalletItem::getCartonMarks).collect(Collectors.toSet()); + var physical = returnCartons.listActivePallet(boundWarehouseId, palletCode); + if (physical.size() != expectedMarks.size() || physical.stream().anyMatch(carton -> + !task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark()) + || !"OUTBOUND_PENDING".equals(carton.getStatus()))) + throw conflict("托盘内容或出库预留已变化,请刷新核对"); + DeliveryResponses.PdaTask result = taskResult(outbound, task); + for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) { + if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue; + result = pickResolved(boundWarehouseId, operatorId, code, + new DeliveryRequests.PdaPick(item.getId(), null, 1L, + childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true); + } + return result; + } + List plans = new ArrayList<>(); int sequence = 0; for (OutboundPalletItem item : palletItems) { @@ -1052,6 +1121,10 @@ public class DeliveryTransferService { + value(position.getPickedPackages()) + value(position.getReceivingPackages())) .sum(); if (remaining != 0) continue; + if (inventoryPositions.activePhysicalPallet(warehouseId, palletId) > 0) continue; + List carrierCodes = items.stream().filter(item -> palletId.equals(item.getWarehousePalletId())) + .map(OutboundPalletItem::getPalletNo).filter(StringUtils::hasText).distinct().toList(); + if (carrierCodes.stream().anyMatch(code -> !returnCartons.listActivePallet(warehouseId, code).isEmpty())) continue; Long lockId = null; WarehouseTransferPallet transferPallet = warehouseTransferPallets.selectOne(new LambdaQueryWrapper() .eq(WarehouseTransferPallet::getCustomerId, customerId) @@ -1071,11 +1144,14 @@ public class DeliveryTransferService { @Transactional public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) { + required(request == null ? null : request.reason(), "取消原因不能为空"); OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); if (observed == null) throw notFound("Outbound order not found"); - // Dispatch picking/departure owns the same dispatch -> outbound lock order. + // Match dispatch picking and relabel confirmation lock order before locking outbound. DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); + RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType()) + ? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null; OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); if ("CANCELLED".equals(outbound.getStatus())) return; @@ -1098,6 +1174,34 @@ public class DeliveryTransferService { if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); + if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + if (application == null || !customerId.equals(application.getCustomerId()) + || !outbound.getOutboundNo().equals(application.getOutboundOrderNo()) + || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) + throw conflict("换标出库关联状态已变化,不能取消"); + for (OutboundPalletItem item : outboundItems) { + if (item.getWarehousePalletId() != null) continue; + for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { + ReturnInventoryCarton carton = returnCartons.lockMark(mark); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !Objects.equals(application.getId(), carton.getRelabelApplicationId()) + || !"OUTBOUND_PENDING".equals(carton.getStatus())) + throw conflict("退货换标箱预留状态异常,不能取消出库"); + carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); + returnCartons.updateById(carton); + } + } + relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId, + new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim())); + } + if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { + for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) { + ReturnInventoryCarton carton = returnCartons.lockMark(mark); + if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) + throw conflict("退货箱预留状态异常,不能取消出库"); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + } + } for (DeliveryOrder delivery : existingDeliveries) { int voidRequests = 0; for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) { @@ -1139,7 +1243,7 @@ public class DeliveryTransferService { } return; } - if (!existingDeliveries.isEmpty()) { + if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList(); for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) { @@ -1180,7 +1284,8 @@ public class DeliveryTransferService { task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(), item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus(), - "RETURN_OUTBOUND".equals(outbound.getOutboundType()) ? returnPickPositions(outbound, task, item) + ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) + || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item) : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } @@ -1219,28 +1324,22 @@ public class DeliveryTransferService { if (item == null || !outbound.getId().equals(item.getOutboundOrderId()) || !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在"); if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1) - throw conflict("退货出库须逐箱扫描,无需库存仓位"); - if (request.cartonMarks() == null || request.cartonMarks().size() != 1 || request.cartonMarks().get(0) == null - || !Objects.equals(item.getCartonMarks(), request.cartonMarks().get(0).trim())) + throw conflict("退货出库须逐箱或逐SKU扫描,无需库存仓位"); + if (StringUtils.hasText(item.getSkuCode())) { + if (!Objects.equals(item.getSkuCode(), trim(request.skuCode()))) throw conflict("请扫描指定的退货SKU"); + } else if (request.cartonMarks() == null || request.cartonMarks().size() != 1 + || !Objects.equals(item.getCartonMarks(), trim(request.cartonMarks().get(0)))) throw conflict("请扫描当前退货出库明细指定的箱唛"); } - private void requireReturnSource(OutboundOrder outbound, OutboundPalletItem item, ReturnInventoryCarton carton) { + private List returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, OutboundPalletItem item) { + ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks()); if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId()) || !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) - || !Objects.equals(item.getInboundCode(), carton.getSourceReturnNo()) - || !Objects.equals(item.getCartonMarks(), carton.getCartonMark())) - throw conflict("退货箱唛与本次出库的客户、仓库或来源退货单不一致"); - } - - private List returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, - OutboundPalletItem item) { - ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks()); - requireReturnSource(outbound, item, carton); - boolean pending = Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) && value(item.getPickedPackageCount()) == 0; - long available = pending && "AVAILABLE".equals(carton.getStatus()) ? 1 : 0; + || !returnStock.matchesReturnSource(carton, item.getInboundCode())) throw conflict("退货箱出库来源异常"); return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(), - item.getInboundCode(), available, value(item.getPickedPackageCount()))); + item.getInboundCode(), Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) + && value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount()))); } private List pdaPickPositions(String customerId, String warehouseId, @@ -1286,7 +1385,7 @@ public class DeliveryTransferService { && Objects.equals(item.getInboundCode(), balance.getInboundCode()); } - private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), outbound.getOutboundType(), task.getPushedAt(), task.getCompletedAt()); } + private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt(), outbound.getOutboundType()); } private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(), outbound.getDispatchNo()); @@ -1353,12 +1452,33 @@ public class DeliveryTransferService { public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); + OutboundOrder sourceOutbound = outboundOrders.selectById(delivery.getOutboundOrderId()); + if (sourceOutbound != null && customerId.equals(sourceOutbound.getCustomerId()) + && ("RETURN_OUTBOUND".equals(sourceOutbound.getOutboundType()) + || ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType()) + && Objects.toString(delivery.getDeliveryGroupKey(), "").startsWith("RETURN_RELABEL:")))) { + List cargo = new ArrayList<>(); + for (OutboundPalletItem item : outboundPallets.findByOutbound(customerId, sourceOutbound.getId())) { + if ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType()) + && !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue; + ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks()); + if (carton == null) throw conflict("退货派送单来源箱记录缺失"); + for (var line : returnCartonItems.listCarton(carton.getId())) + cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(), + carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null, + line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity())); + } + return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo, + feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), + deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null); + } List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); + var regularItems = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage())).toList(); var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream() - .filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList(); + .filter(row -> regularItems.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList(); Set sourceOrderIds = new LinkedHashSet<>(); - items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add); + regularItems.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add); picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId) .filter(Objects::nonNull).forEach(sourceOrderIds::add); List orderIds = List.copyOf(sourceOrderIds); @@ -1366,7 +1486,19 @@ public class DeliveryTransferService { .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); List details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList( new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)); - List skus = deliveryCargoSkus(items, picked, ordersById, details); + List skus = new ArrayList<>(deliveryCargoSkus(regularItems, picked, ordersById, details)); + for (DispatchItem item : items) { + if (!"RETURN_RELABEL".equals(item.getSourceStage())) continue; + for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { + ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, mark); + if (carton == null || !Objects.equals(delivery.getOverseasWarehouseId(), carton.getOverseasWarehouseId())) + throw conflict("退货换标派送单来源箱记录缺失"); + for (var line : returnCartonItems.listCarton(carton.getId())) + skus.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(), + item.getFbaWarehouseCode(), delivery.getDeliveryAddress(), null, null, + line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity())); + } + } return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 0c6552627..60d987237 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -77,10 +77,7 @@ public class CargoDiscardController { private String scope(AuthenticatedUser user, String requested) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requested == null || requested.isBlank() || "0".equals(requested)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requested.trim(); + return requested == null || requested.isBlank() || "0".equals(requested) ? null : requested.trim(); } return String.valueOf(user.tenantId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 1fa1c8e8f..14fb36737 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -14,7 +14,7 @@ public final class CargoDiscardResponses { List skus) {} public record CandidateSku(String skuCode, String productName, long availablePackages) {} - public record Summary(Long id, String discardNo, String inboundCodes, + public record Summary(Long id, String customerId, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index c7d1268ef..56fccc1b7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -51,7 +51,7 @@ import java.util.Set; @Service public class CargoDiscardService { @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; - public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve"; + public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; @@ -171,36 +171,7 @@ public class CargoDiscardService { application.setCreatedAt(now); application.setUpdatedAt(now); applications.insert(application); - // A discard request is also a customer-visible tail-leg order. Keep the - // application as the workflow record and link the order through its - // immutable source id so cancellation/status changes stay traceable. - LastlegOrder tailOrder = new LastlegOrder(); - tailOrder.setId(IdWorker.getId()); - tailOrder.setOrderNo(application.getDiscardNo()); - tailOrder.setCustomerId(customerId); - tailOrder.setOrderType("CARGO_DISCARD"); - tailOrder.setInboundCode(application.getInboundCodes()); - tailOrder.setInboundOrderNo(application.getDiscardNo()); - tailOrder.setSourceRegion("LASTLEG_DISCARD"); - tailOrder.setSourceInboundId("CARGO_DISCARD:" + application.getId()); - tailOrder.setSourceOrderId(String.valueOf(application.getId())); - tailOrder.setSourceOrderNo(application.getDiscardNo()); - tailOrder.setOverseasWarehouseId(warehouseId); - tailOrder.setOverseasWarehouseName(warehouseName); - tailOrder.setPackageCount(total); - tailOrder.setCurrentStatus(PENDING_REVIEW); - tailOrder.setVersion(0); - tailOrder.setCreatedAt(now); - tailOrder.setUpdatedAt(now); - tailOrder.setSourceEventId("CARGO_DISCARD:" + application.getId()); - tailOrder.setSourceLoadingPlanId("CARGO_DISCARD"); - tailOrder.setSourcePayloadHash(requestHash); - orders.insert(tailOrder); - LastlegOrderStatusHistory initialOrderHistory = new LastlegOrderStatusHistory(); - initialOrderHistory.setId(IdWorker.getId()); initialOrderHistory.setCustomerId(customerId); initialOrderHistory.setOrderId(tailOrder.getId()); - initialOrderHistory.setFromStatus(null); initialOrderHistory.setToStatus(PENDING_REVIEW); - initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now); - orderHistories.insert(initialOrderHistory); + // 废弃申请独立承载废弃流程,不创建尾程订单。 Set reservedReturnCartons = new LinkedHashSet<>(); for (CargoDiscardRequests.Item input : request.items()) { @@ -218,7 +189,7 @@ public class CargoDiscardService { public PageResult page(String customerId, CargoDiscardRequests.Query query) { validateQuery(query); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(CargoDiscardApplication::getCustomerId, customerId) + .eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId) .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, @@ -432,7 +403,7 @@ public class CargoDiscardService { } private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { - return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), + return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), value.getReviewedAt()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 52a94c557..a8d9e405d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -15,10 +15,22 @@ public final class DispatchRequests { } public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, - String vehiclePlate, String status, String fbaAppointmentNo) { + String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public enum Stage { + BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED; + public List statuses() { + return switch (this) { + case BEFORE_LOADING -> List.of("PENDING_DEPARTURE"); + case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART"); + case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED"); + case CANCELLED -> List.of("CANCELLED"); + }; + } + } + public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java index b487c19b0..1eacc603e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -12,7 +12,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, int itemCount, int palletCount, int packageCount, int pickedPackageCount, int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, LocalDateTime createdAt, - List items, List bolLines) { + List items, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index b676baa75..09cbd9984 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -258,6 +258,27 @@ public interface DispatchCandidateMapper { @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + @Select("") + List nativeReturnRelabelAvailable(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index 9b57005ec..a6d90e94e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -44,6 +44,7 @@ public class DispatchPalletCandidate { private String contactPhone; private Long returnReceiptId; private String returnReceiptNo; + private boolean returnCartonSource; @com.fasterxml.jackson.annotation.JsonProperty("sourceType") public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java index 0d5613033..f8591d251 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -8,8 +8,12 @@ import jakarta.validation.constraints.Size; public final class DispatchPdaRequests { private DispatchPdaRequests() {} - public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, - @NotBlank String idempotencyKey, String remark) {} + public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) { + public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { + this(positionId, packages, productQuantity, idempotencyKey, remark, null); + } + } public record Depart(@NotBlank String idempotencyKey, String remark) {} public record CompletePicking(@NotBlank String idempotencyKey) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java index 3a0c1aa9e..845cc9847 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -16,7 +16,15 @@ public final class DispatchPdaResponses { public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, int scheduledPackages, - long pickedPackages, List positions) {} + long pickedPackages, List positions, List returnCartons) { + public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, + int scheduledPackages, long pickedPackages, List positions) { + this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, + warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of()); + } + } + public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {} public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, long pickedPackages) {} public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java index fe47e73c9..12daa7f71 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -15,6 +15,7 @@ public class DispatchPdaPick { private Long taskId; private Long dispatchItemId; private Long inventoryPositionId; + private Long returnCartonId; private Long packages; private Long productQuantity; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index c59eb46d5..225cb9aa5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -16,6 +16,7 @@ public class DispatchPdaPickOrder { private Long pickId; private Long receiptId; private Long returnReceiptId; + private Long returnCartonId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 3eac86651..8e297a778 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -54,6 +54,7 @@ public class DispatchPdaWorkflowService { @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock; @Transactional(readOnly = true) public List list(String warehouseId, String status) { @@ -115,13 +116,23 @@ public class DispatchPdaWorkflowService { throw notFound("Dispatch pallet item not found"); } if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); - InventoryPosition position = positions.selectById(request.positionId()); - if (position == null || !customerId.equals(position.getCustomerId()) - || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage()); + if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位"); + if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位"); + InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId()); + if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) { throw conflict("Inventory position does not belong to this dispatch pallet"); } long picked = picks.totalItem(customerId, task.getId(), itemId); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + long pickId = IdWorker.getId(); + Long returnCartonId = null; + if (nativeReturn) { + pickAttribution.prepareNativeReturn(customerId, item.getOrderId()); + returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(), + pickId, order.getDispatchNo()).getId(); + } else { pickAttribution.preparePicking(customerId, request.positionId(), "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); @@ -142,12 +153,14 @@ public class DispatchPdaWorkflowService { inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":SHIP", request.remark())); + } DispatchPdaPick pick = new DispatchPdaPick(); - pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); + pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId); pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); - pickAttribution.attribute(customerId, pick, + if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId()); + else pickAttribution.attribute(customerId, pick, "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); @@ -289,6 +302,13 @@ public class DispatchPdaWorkflowService { private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { List values = new ArrayList<>(); for (DispatchItem item : source) { + if ("RETURN_RELABEL".equals(item.getSourceStage())) { + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item))); + continue; + } List palletPositions = item.getWarehousePalletId() == null ? List.of() : positions.listPallet(customerId, item.getWarehousePalletId()); boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 3c1bcc9e5..9d1f51a3d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -162,6 +162,24 @@ public class DispatchPickOrderAttributionService { } public record OrderPackages(Long orderId, long packages) {} + /** Use the same order-before-stock lock order as ordinary dispatch picks. */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void prepareNativeReturn(String customerId, Long orderId) { + LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) + throw conflict("退货换标履约订单缺失或暂扣"); + } + /** Native return boxes have their own stock ledger, but fulfill this relabel tail order. */ + public void attributeNativeReturn(String customerId, DispatchPdaPick pick, Long orderId) { + LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) throw conflict("退货换标履约订单缺失或暂扣"); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); row.setOrderId(orderId); + row.setReturnCartonId(pick.getReturnCartonId()); + row.setPackages(pick.getPackages()); row.setProductQuantity(pick.getProductQuantity()); row.setCreatedAt(pick.getPickedAt()); + if (attributions.insert(row) != 1 || orders.increasePicked(customerId, orderId, pick.getPackages()) != 1) + throw conflict("退货换标排车拣货归属或箱数不一致"); + } private static long value(Long value) { return value == null ? 0 : value; } private static long value(Integer value) { return value == null ? 0 : value; } private static ResponseStatusException conflict(String message) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 62a86b738..0ce8e808d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -75,8 +75,10 @@ public class DispatchService { @Resource private LastlegOrderMapper tailOrders; @Resource private OutboundOrderMapper outboundOrders; @Resource private FleetDispatchBindingGuard fleetBindings; + @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; + @Resource private ReturnRelabelDispatchStock returnRelabelStock; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -121,7 +123,9 @@ public class DispatchService { List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage), trimToNull(draftToken)); if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { - List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); + List relabel = java.util.stream.Stream.concat( + candidates.relabelAvailable(null, trimToNull(orderNo), null).stream(), + returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList(); if (relabel != null && !relabel.isEmpty()) { result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") @@ -566,15 +570,22 @@ public class DispatchService { @Transactional private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo()); + RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo()); if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); } relabelBindingGuard.requireBound(application); - List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); + if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); + List selected = java.util.stream.Stream.concat( + candidates.relabelAvailable(customerId, null, request.relabelNo()).stream(), + returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); + if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource) + && !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘"); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); - if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); + if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); String warehouse = selected.get(0).overseasWarehouseId(); if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); LocalDateTime now = LocalDateTime.now(); @@ -583,6 +594,12 @@ public class DispatchService { dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) + && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { + item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item); + } + } if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } @@ -623,11 +640,16 @@ public class DispatchService { .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .in(query.stage() != null, DispatchOrder::getStatus, + query.stage() == null ? List.of() : query.stage().statuses()) .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, query.fbaAppointmentNo()) .orderByDesc(DispatchOrder::getCreatedAt)); + var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream() + .map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.of(); List records = page.getRecords().stream() - .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList(); + .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()), + List.of(), workflows.get(order.getDispatchNo()))).toList(); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -679,6 +701,8 @@ public class DispatchService { requirePalletEditingAllowed(dispatch); DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId); if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); + if ("RETURN_RELABEL".equals(item.getSourceStage())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车"); List activeLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) @@ -728,10 +752,17 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); } String customerId = dispatch.getCustomerId(); + if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) + throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱"); + if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null) + throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); LocalDateTime now = LocalDateTime.now(); dispatch.setStatus(CANCELLED); dispatch.setUpdatedAt(now); orders.updateById(dispatch); + for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); + } items.findByDispatchOrder(customerId, dispatch.getId()).stream() .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() @@ -764,6 +795,11 @@ public class DispatchService { private DispatchResponse response(DispatchOrder order, List itemList, List bolLineList) { + return response(order, itemList, bolLineList, null); + } + + private DispatchResponse response(DispatchOrder order, List itemList, + List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) .distinct().collect(Collectors.joining(",")); return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), @@ -782,7 +818,7 @@ public class DispatchService { item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), - bolLineList == null ? List.of() : bolLineList); + bolLineList == null ? List.of() : bolLineList, workflow); } private static String candidateRoute(List selected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java new file mode 100644 index 000000000..887b5d037 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** Native return stock stays in its own ledger; no synthetic InventoryPosition rows. */ +@Service +public class ReturnRelabelDispatchStock { + @Resource private DispatchCandidateMapper candidates; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private PalletPhysicalGateway physical; + @Resource private ReturnInventoryService inventory; + + public List candidates(String customer, String orderNo, String relabelNo) { + List result = new ArrayList<>(); + for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) { + var pallet = physical.resolve(candidate.palletNo()); + if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId()) + || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue; + candidate.warehousePalletId(pallet.palletId()); + result.add(candidate); + } + return result; + } + + public void reserve(DispatchItem item) { + for (String mark : marks(item).stream().sorted().toList()) { + var carton = require(item, mark, "RELABEL_PENDING"); + carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(LocalDateTime.now()); + if (cartons.updateById(carton) != 1) throw conflict("退货箱排车预占失败"); + } + } + + public void release(DispatchItem item) { + for (String mark : marks(item).stream().sorted().toList()) { + var carton = require(item, mark, "OUTBOUND_PENDING"); + carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(LocalDateTime.now()); + if (cartons.updateById(carton) != 1) throw conflict("退货箱排车取消释放失败"); + } + } + + public ReturnInventoryCarton pick(DispatchItem item, String mark, long packages, long quantity, long pickId, String dispatchNo) { + if (packages != 1 || !marks(item).contains(mark)) throw conflict("请扫描本排车的来源箱唛,每次拣货一箱"); + var carton = require(item, mark, "OUTBOUND_PENDING"); + if (quantity != carton.getTotalQuantity()) throw conflict("退货箱 SKU 总数量已变化,请刷新后重试"); + inventory.shipCarton(item.getCustomerId(), item.getOverseasWarehouseId(), carton.getSourceReturnNo(), + carton.getCartonMark(), dispatchNo, pickId); + return carton; + } + + public List detail(DispatchItem item) { + return marks(item).stream().map(mark -> { + var carton = cartons.findCustomerMark(item.getCustomerId(), mark); + if (carton == null || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) + throw conflict("排车来源退货箱缺失"); + return new DispatchPdaResponses.ReturnCarton(carton.getId(), mark, carton.getStatus(), carton.getTotalQuantity()); + }).toList(); + } + + private ReturnInventoryCarton require(DispatchItem item, String mark, String status) { + var carton = cartons.lockMark(mark); + if (carton == null || !item.getCustomerId().equals(carton.getCustomerId()) + || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !Objects.equals(item.getPalletNo(), carton.getPalletCode()) + || !Objects.equals(item.getSourceBusinessNo(), carton.getRelabelNo()) || !status.equals(carton.getStatus())) + throw conflict("退货箱已被占用或来源、客户、仓库、托盘不一致"); + return carton; + } + private Set marks(DispatchItem item) { + return new LinkedHashSet<>(Arrays.asList(Objects.toString(item.getCartonMarks(), "").split(","))); + } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 6b1262676..9be19ed6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -18,6 +18,8 @@ public final class LastlegOrderRequests { private LastlegOrderRequests() { } + public enum SailingStage { BEFORE_DEPARTURE, AFTER_DEPARTURE } + public record Query( @Min(1) Long page, @Min(1) @Max(200) Long size, @@ -34,7 +36,8 @@ public final class LastlegOrderRequests { Boolean abnormal, Boolean detained, LocalDateTime createdFrom, - LocalDateTime createdTo + LocalDateTime createdTo, + SailingStage sailingStage ) { public Query { if (page == null || page == 0) page = 1L; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java new file mode 100644 index 000000000..22ae6a31f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.export; + +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import java.util.List; +import java.util.Map; +import java.util.Arrays; + +/** Explicit business columns; internal database identifiers are never exported. */ +final class LastlegExportColumns { + private LastlegExportColumns() { } + private static final Map DEFINITIONS = Map.ofEntries( + Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"), + Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"), + Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"), + Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"), + Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "month:月份,baseCurrency:基准币种,quoteCurrency:报价币种,usdToCnyAverage:月平均汇率:DECIMAL,status:状态,updatedAt:更新时间:DATETIME"), + Map.entry("VEHICLE_LIST", "plateNumber:车牌号,model:车型,fuelConsumptionPer100Km:油耗(L/100km):DECIMAL,mileageKm:当前公里码(km):DECIMAL,driverOrDispatchCompany:司机或外派单位,phone:联系电话,region:区域,ownership:车辆归属,status:状态,maxPalletSpaces:最大托位:DECIMAL,maxLoadKg:最大载重(kg):DECIMAL,maxVolumeCbm:可用容积(m³):DECIMAL,updatedAt:更新时间:DATETIME"), + Map.entry("FLEET_QUOTE_LIST", "quoteNo:报价号,details.kind:用途,partyName:往来单位,details.originCity:发货城市,details.destinationCode:目的仓码,details.distanceMiles:预估距离(mile):DECIMAL,details.currency:币种,details.selfPickupPrice:整车自提(每单):DECIMAL,details.fullDeliveryPrice:整车派送(每单):DECIMAL,details.looseDeliveryPrice:散板派送(每托):DECIMAL,details.validFrom:生效日:DATE,details.validTo:失效日:DATE,status:状态,recordVersion:版本:INTEGER,details.remark:备注"), + Map.entry("TRIP_OPERATIONS_LIST", "tripNo:车次号,status:状态,vehiclePlate:车牌,driverName:司机,warehouseName:起运仓,departedAt:实际发车UTC,completedAt:货物结案UTC,destinations:目的地,physicalPallets:实物托盘数:INTEGER,palletSpaces:标准托位:INTEGER,ownPackages:自有箱数:INTEGER,externalPackages:外接箱数:INTEGER,externalAgreedAmounts:外接约定金额(按币种),measured.actualDistanceMiles:实际里程(mile):DECIMAL,measured.actualFuelLitres:实际油耗(L):DECIMAL,measured.remark:实测备注,financeStatus:应收与实收,costs:直接成本")); + static List columns(String type) { + String definition = DEFINITIONS.get(type); + if (definition == null) throw new IllegalArgumentException("Unsupported Lastleg export type"); + return Arrays.stream(definition.split(",")).map(value -> { + String[] parts = value.split(":"); + return new ExportColumn(parts[0], parts[1], + parts.length == 3 ? ExportColumnType.valueOf(parts[2]) : ExportColumnType.STRING, 24); + }).toList(); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java new file mode 100644 index 000000000..5e680e9e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.export; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/exports/v1") +public class LastlegExportProviderController { + private final InternalServiceTokenGuard guard; + private final LastlegExportProviderService service; + + public LastlegExportProviderController(InternalServiceTokenGuard guard, LastlegExportProviderService service) { + this.guard = guard; + this.service = service; + } + + @PostMapping("/{exportType}/batches") + public ApiResponse batch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String exportType, @RequestBody ExportBatchRequest request) { + guard.verify(token); + return ApiResponse.success(service.batch(exportType, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java new file mode 100644 index 000000000..53df619ac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java @@ -0,0 +1,146 @@ +package com.baiyue.lastleg.export; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests; +import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService; +import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.fleet.quote.FleetQuoteModels; +import com.baiyue.lastleg.fleet.quote.FleetQuoteService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsModels; +import com.baiyue.lastleg.fleet.reporting.TripOperationsService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.stream.Collectors; + +/** Internal, platform-only batches. Reuses the same filters and business projections as the lists. */ +@Service +public class LastlegExportProviderService { + @Resource private LastlegOrderService orders; + @Resource private InventoryQueryService inventory; + @Resource private OverseasQuoteService quotes; + @Resource private MonthlyExchangeRateService rates; + @Resource private VehicleApplicationService vehicles; + @Resource private FleetQuoteService fleetQuotes; + @Resource private TripOperationsService operations; + @Resource private ObjectMapper json; + @Resource private Validator validator; + @Value("${baiyue.security.internal-service-token}") private String signingKey; + + public ExportBatchResponse batch(String type, ExportBatchRequest request) { + if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出"); + } + if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1 + || request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效"); + List columns; + try { columns = LastlegExportColumns.columns(type); } + catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); } + int offset = cursor(request.cursor()); + if (offset % request.limit() != 0) throw invalid("导出游标无效"); + String snapshot = signature(type, request); + boolean first = request.snapshotToken() == null; + if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8), + request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配"); + Map parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters()); + String customer = text(parameters.remove("customerId")); + if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效"); + String relabelNo = text(parameters.remove("relabelNo")); + boolean costVisible = Boolean.TRUE.equals(parameters.remove("costVisible")); + parameters.put("page", offset / request.limit() + 1); + parameters.put("size", request.limit()); + PageResult page = switch (type) { + case "LAST_MILE_ORDER_LIST" -> orders.page(customer, query(parameters, LastlegOrderRequests.Query.class), relabelNo); + case "OVERSEAS_INVENTORY_BALANCE" -> inventory.exportBalancePage(customer, query(parameters, InventoryQueryRequests.BalanceQuery.class)); + case "OVERSEAS_INVENTORY_LEDGER" -> inventory.ledgers(customer, query(parameters, InventoryQueryRequests.LedgerQuery.class)); + case "OVERSEAS_QUOTE_LIST" -> quotes.page(query(parameters, OverseasQuoteRequests.Query.class)); + case "MONTHLY_EXCHANGE_RATE_LIST" -> rates.page(query(parameters, MonthlyExchangeRateDtos.Query.class)); + case "VEHICLE_LIST" -> vehicles.page(query(parameters, VehicleModels.Query.class)); + case "FLEET_QUOTE_LIST" -> fleetQuotes.page(query(parameters, FleetQuoteModels.Query.class)); + case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page(); + default -> throw invalid("不支持该导出类型"); + }; + List> rows = page.records().stream().map(value -> row(value, columns)).toList(); + int next = offset + rows.size(); + if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进"); + return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows, + String.valueOf(next), next < page.total(), first ? page.total() : null); + } + + private T query(Map parameters, Class type) { + Set names = Arrays.stream(type.getRecordComponents()).map(c -> c.getName()).collect(Collectors.toSet()); + if (!names.containsAll(parameters.keySet())) throw invalid("包含不支持的导出筛选条件"); + try { + T value = json.convertValue(parameters, type); + if (!validator.validate(value).isEmpty()) throw invalid("导出筛选条件无效"); + return value; + } catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); } + } + + private Map row(Object value, List columns) { + JsonNode source = json.valueToTree(value); + Map result = new LinkedHashMap<>(); + for (ExportColumn column : columns) { + JsonNode node = source; + for (String part : column.key().split("\\.")) node = node.path(part); + Object cell = node.isMissingNode() || node.isNull() ? null + : node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText(); + result.put(column.key(), cell); + } + if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) { + result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否"); + result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否"); + result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus())); + } + if (value instanceof TripOperationsModels.Row trip) { + result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / "))); + result.put("externalAgreedAmounts", trip.externalAgreedAmounts().stream().map(m -> m.currency() + " " + m.amount().toPlainString()).collect(Collectors.joining(" / "))); + result.put("financeStatus", "未查询财务"); + result.put("costs", trip.costs() == null ? "无成本读取权限" : trip.costs().totals().isEmpty() ? "未登记成本" + : trip.costs().totals().stream().map(t -> t.currency() + " " + t.directCost().toPlainString()).collect(Collectors.joining(" / "))); + } + return result; + } + + private String signature(String type, ExportBatchRequest r) { + try { + Map context = new TreeMap<>(); + context.put("type", type); context.put("task", r.taskId()); context.put("requester", r.requestedBy()); + context.put("dispatch", r.dispatchVersion()); context.put("limit", r.limit()); + context.put("parameters", new TreeMap<>(r.parameters() == null ? Map.of() : r.parameters())); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(json.writeValueAsBytes(context))); + } catch (Exception e) { throw new IllegalStateException("无法签署导出筛选快照", e); } + } + + private String hash(List columns) { + try { return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(columns))); } + catch (Exception e) { throw new IllegalStateException(e); } + } + private static int cursor(String value) { + if (value == null) return 0; + try { int offset = Integer.parseInt(value); if (offset < 0 || offset > 200_000) throw invalid("导出游标无效"); return offset; } + catch (NumberFormatException e) { throw invalid("导出游标无效"); } + } + private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString().trim(); } + private static ResponseStatusException invalid(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java index 86f480fe3..eec532725 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java @@ -61,11 +61,6 @@ public class FleetQuoteService { for(int i=0;iT read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}} private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} - private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";} private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);} private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java index fcb1bac40..0211ff9c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java @@ -35,7 +35,5 @@ public class PlatformFleetQuoteController { public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));} @PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')") public ApiResponse importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));} - @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')") - public ResponseEntity export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));} private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java index b0e86d9ae..0e4af3aba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -17,11 +17,6 @@ public class PlatformTripOperationsController { public PlatformTripOperationsController(TripOperationsService service,TripOperationsFinanceService finance) { this.service=service;this.finance=finance; } @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); } - @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')") - public ResponseEntity export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { - return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) - .header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user))); - } @GetMapping("/{tripNo}/finance") @PreAuthorize("hasAuthority('platform:fleet:operations:list') and hasAuthority('platform:finance:customer-arap:list')") public ApiResponse finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java index 13deda172..9460c8b9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -120,17 +120,7 @@ public class TripOperationsService { return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values())); } private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} - public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) { - var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n"); - for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()), - cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()), - cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))), - cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n"); - return csv.toString().getBytes(StandardCharsets.UTF_8); - } - private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));} private static List amounts(Map money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); } private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); } - static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; } private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java index 188c4731c..10f34198d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java @@ -7,7 +7,9 @@ public interface FleetCapacityOfferMapper extends BaseMapper SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id - o.status='OPEN' + o.status='OPEN' AND t.status='PLANNED' + AND o.cutoff_at > #{now} AND o.cutoff_at <= t.planned_departure_at + AND o.status=#{q.status} AND o.trip_no=#{q.tripNo} AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%')) @@ -21,7 +23,7 @@ public interface FleetCapacityOfferMapper extends BaseMapper pageOffers( com.baomidou.mybatisplus.extension.plugins.pagination.Page page, - @Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly); + @Param("q") FleetCapacityOfferModels.Query query,@Param("availableOnly") boolean availableOnly,@Param("now") java.time.LocalDateTime now); @Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}") FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo); @Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index 875513689..51d53dd31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -16,8 +16,8 @@ public final class FleetCapacityOfferModels { public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination, LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status, - Boolean canConfirm,@Size(max=64) String tripNo) { - public Query {page=page==null?1:page;size=size==null?20:size;} + Boolean canConfirm,@Size(max=64) String tripNo,@Pattern(regexp="AVAILABLE|ALL") String scope) { + public Query {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"AVAILABLE":scope;} } public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index d89d3054f..0d2ac1b1e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -52,7 +52,8 @@ public class FleetCapacityOfferService { @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult page(FleetCapacityOfferModels.Query query){ readPermission(); - var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage()); + if("ALL".equals(query.scope())&&!manage())throw forbidden(); + var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!"ALL".equals(query.scope()),LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC)); if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围"); var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false))) .filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java new file mode 100644 index 000000000..2c9fef6c2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; + +/** Read existing reviews in batches; never infer approval from dispatch execution status. */ +@Service +public class FleetDispatchWorkflowQuery { + public record Review(String status, String reason, java.time.Instant reviewedAt) { } + public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { } + private final FleetTripDispatchMapper bindings; + private final FleetTripMapper trips; + private final ObjectMapper json; + + public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) { + this.bindings = bindings; this.trips = trips; this.json = json; + } + + public Map read(List dispatchNos) { + if (dispatchNos.isEmpty()) return Map.of(); + var members = bindings.findByDispatchNos(dispatchNos); + if (members.isEmpty()) return Map.of(); + var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList()) + .stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip)); + Map result = new HashMap<>(); + for (var member : members) { + var trip = parents.get(member.getTripId()); + if (trip == null) throw new IllegalStateException("关联车次不存在"); + result.put(member.getDispatchNo(), new Workflow(member.getTripNo(), + review(member.getReviewJson()), review(trip.getLoadingReviewJson()))); + } + return result; + } + + private Review review(String value) { + var state = FleetTripReview.read(json, value); + return new Review(state.status(), state.reason(), state.reviewedAt()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java index 6c39fe4e8..5aa78e426 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -26,6 +26,12 @@ public interface InventoryPositionMapper extends BaseMapper { List listPallet(@Param("customerId") String customerId, @Param("palletId") Long palletId); + /** Physical carriers can contain stock owned by more than one customer. */ + @Select("SELECT COUNT(*) FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_id=#{palletId} AND (available_packages>0 OR hold_packages>0 OR allocated_packages>0 " + + "OR picked_packages>0 OR receiving_packages>0)") + long activePhysicalPallet(@Param("warehouseId") String warehouseId, @Param("palletId") Long palletId); + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") InventoryPosition lockPosition(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index eec531970..05c721b56 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -87,28 +87,6 @@ public class InventoryQueryController { return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); } - @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") - @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportBalances( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @Valid InventoryQueryRequests.BalanceQuery query) { - String scope = platformScope(user, customerId); - return csv(service.exportBalances(scope, query), "inventory-balances.csv"); - } - - @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") - @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportLedgers( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @Valid InventoryQueryRequests.LedgerQuery query) { - String scope = platformScope(user, customerId); - return csv(service.exportLedgers(scope, query), "inventory-ledger.csv"); - } - private static ResponseEntity csv(byte[] content, String filename) { return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java index de7467004..44f9cb578 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -50,7 +50,8 @@ public final class InventoryQueryRequests { String skuCode, String warehouseLocationCode, LocalDateTime occurredFrom, - LocalDateTime occurredTo + LocalDateTime occurredTo, + String customerName ) { public LedgerQuery { if (page == null || page == 0) page = 1L; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 04d7c03d3..03702fad3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -91,6 +91,9 @@ public interface InventoryQueryMapper { long countBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query); + @Select("") + long countSourceBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query); + @Select("") List sourceBalances(@Param("customerId") String customerId, @@ -148,6 +151,7 @@ public interface InventoryQueryMapper { InventoryBalance visibleBalance(@Param("balanceId") long balanceId); String LEDGER_FILTER = " WHERE 1=1" + + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND customer_id=#{customerId}" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index 7cccb88fe..c768d2dd3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -89,65 +89,12 @@ public class InventoryQueryService { return page(records, query.page(), query.size(), total); } - public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { - try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { - writeBalancesCsv(customerId, query, output); - return output.toByteArray(); - } catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); } - } - - public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output) - throws IOException { + public PageResult exportBalancePage(String customerId, InventoryQueryRequests.BalanceQuery query) { validateStatus(query.inventoryStatus(), BALANCE_STATUSES); - output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + - "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," + - "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8)); - final int batchSize = 1000; - long offset = 0; - while (true) { - List batch = mapper.sourceBalances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList(); - if (batch.isEmpty()) break; - for (InventoryBalanceView v : batch) output.write(( - String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), - csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), - csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), - csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), - csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), - csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8)); - output.flush(); - if (batch.size() < batchSize) break; - offset += batchSize; - } - } - - public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { - try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { - writeLedgersCsv(customerId, query, output); - return output.toByteArray(); - } catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); } - } - - public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output) - throws IOException { - validateDateRange(query); - output.write(("\uFEFFoccurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + - "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + - "beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8)); - final int batchSize = 1000; - long offset = 0; - while (true) { - List batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList(); - if (batch.isEmpty()) break; - for (InventoryLedgerView v : batch) output.write(( - String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseId()), - csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), - csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), - csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), - csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8)); - output.flush(); - if (batch.size() < batchSize) break; - offset += batchSize; - } + long total = mapper.countSourceBalances(customerId, query); + var rows = mapper.sourceBalances(customerId, query, offset(query.page(), query.size()), query.size()) + .stream().map(this::balanceView).toList(); + return page(rows, query.page(), query.size(), total); } private InventoryBalanceView balanceView(InventoryBalance value) { @@ -220,10 +167,4 @@ public class InventoryQueryService { return new PageResult<>(records, page, size, total, pages); } - private static String csv(Object value) { - if (value == null) return ""; - String text = String.valueOf(value); - if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; - return '"' + text.replace("\"", "\"\"") + '"'; - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index ac6290793..97a0b043d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,16 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + // Manual node snapshots replace container nodes permanently for the order. + String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " + + "(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " + + "CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " + + "AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " + + "THEN 1 ELSE 0 END ELSE " + + "CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " + + "WHERE g.container_booking_id=ll_order.source_container_booking_id " + + "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})"; + @Select("") long countPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); - @Select("") @@ -58,7 +79,7 @@ public interface ReturnInventoryBalanceMapper extends BaseMapperAND sku_code LIKE CONCAT('%',#{skuCode},'%') ") long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); - @Select("") + List listCartons(@Param("cartonIds") List cartonIds); @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE") List lockCarton(@Param("cartonId") long cartonId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index 324935209..cfbd23e88 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -10,6 +10,12 @@ import java.util.List; @Mapper public interface ReturnInventoryCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY carton_mark") + List packingAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + + @Update("UPDATE ll_return_inventory_carton SET location_code=NULL,putaway_at=NULL WHERE id=#{id}") + int clearPutaway(@Param("id") long id); + String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " + "AND c.overseas_warehouse_id=#{query.overseasWarehouseId} " + "AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') " + @@ -72,6 +78,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper listReceipt(@Param("receiptId") long receiptId); + @Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE") ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); @@ -81,6 +90,11 @@ public interface ReturnInventoryCartonMapper extends BaseMapper listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_code=#{palletCode} AND status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUND_PENDING') ORDER BY id") + List listActivePallet(@Param("warehouseId") String warehouseId, + @Param("palletCode") String palletCode); + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java index 6bca29bc6..1db42b0a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java @@ -19,4 +19,6 @@ public interface ReturnOrderItemMapper extends BaseMapper { "WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') " + "AND i.sku_code=#{skuCode} AND i.received_quantity activeSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); + @Select("SELECT i.* FROM ll_return_order_item i JOIN ll_return_order o ON o.id=i.return_order_id WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status='COMPLETED' AND i.sku_code=#{skuCode} AND i.received_quantity>0 ORDER BY o.created_at,o.id LIMIT 100") + List completedSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java index ea900cb9e..41557810a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java @@ -9,6 +9,8 @@ import java.util.List; @Mapper public interface ReturnOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' ORDER BY created_at,id") + List completedCustomer(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingPlanMapper.java new file mode 100644 index 000000000..3ff5f58e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingPlanMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.util.List; + +@Mapper +public interface ReturnPackingPlanMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_packing_plan WHERE plan_no=#{planNo} LIMIT 1") + ReturnPackingPlan findNo(@Param("planNo") String planNo); + @Select("SELECT * FROM ll_return_packing_plan WHERE plan_no=#{planNo} LIMIT 1 FOR UPDATE") + ReturnPackingPlan lockNo(@Param("planNo") String planNo); + @Select("SELECT * FROM ll_return_packing_plan WHERE overseas_warehouse_id=#{warehouseId} AND status IN ('READY','IN_PROGRESS') ORDER BY created_at ASC,id ASC LIMIT 100") + List listPending(@Param("warehouseId") String warehouseId); + @Select("SELECT COUNT(1) FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status})") + long countCustomer(@Param("customerId") String customerId, @Param("status") String status); + @Select("SELECT * FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY created_at DESC,id DESC LIMIT #{offset},#{size}") + List listCustomer(@Param("customerId") String customerId, @Param("status") String status, @Param("offset") long offset, @Param("size") long size); + String PLATFORM_FILTER = " WHERE (#{status} IS NULL OR status=#{status}) AND (#{planNo} IS NULL OR LOCATE(#{planNo},plan_no)>0) AND (#{customerId} IS NULL OR customer_id=#{customerId}) AND (#{warehouseId} IS NULL OR overseas_warehouse_id=#{warehouseId}) AND (#{sku} IS NULL OR LOCATE(#{sku},JSON_UNQUOTE(JSON_EXTRACT(boxes_json,'$[*].items[*].skuCode')))>0)"; + @Select("SELECT COUNT(1) FROM ll_return_packing_plan" + PLATFORM_FILTER) + long countPlatform(@Param("status") String status, @Param("planNo") String planNo, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("sku") String sku); + @Select("SELECT * FROM ll_return_packing_plan" + PLATFORM_FILTER + " ORDER BY created_at DESC,id DESC LIMIT #{offset},#{size}") + List listPlatform(@Param("status") String status, @Param("planNo") String planNo, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("sku") String sku, @Param("offset") long offset, @Param("size") long size); + @Select("SELECT * FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + ReturnPackingPlan lockCustomerKey(@Param("customerId") String customerId, @Param("key") String key); + @Update("UPDATE ll_return_packing_plan SET status=#{status},updated_at=#{updatedAt},pushed_at=#{pushedAt},completed_at=#{completedAt} WHERE id=#{id}") + int updateStatus(@Param("id") long id, @Param("status") String status, @Param("updatedAt") java.time.LocalDateTime updatedAt, @Param("pushedAt") java.time.LocalDateTime pushedAt, @Param("completedAt") java.time.LocalDateTime completedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java new file mode 100644 index 000000000..012efe45b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Insert; +import java.time.LocalDateTime; +import java.util.List; + +/** Immutable movements retain original return ownership when cartons are merged again. */ +@Mapper +public interface ReturnPackingTraceMapper { + record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo, + String skuCode, long quantity, LocalDateTime createdAt) { } + record Ownership(String returnNo, long quantity) { } + + @Insert("INSERT INTO ll_return_packing_trace(id,customer_id,operation_id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at) VALUES(#{id},#{customerId},#{operationId},#{source},#{target},#{returnNo},#{sku},#{quantity},#{at})") + int insert(@Param("id") long id, @Param("customerId") String customerId, @Param("operationId") long operationId, + @Param("source") String source, @Param("target") String target, @Param("returnNo") String returnNo, + @Param("sku") String sku, @Param("quantity") long quantity, @Param("at") LocalDateTime at); + + @Select("SELECT return_no, SUM(CASE WHEN target_carton_mark=#{mark} THEN quantity ELSE -quantity END) AS quantity FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND sku_code=#{sku} AND (source_carton_mark=#{mark} OR target_carton_mark=#{mark}) GROUP BY return_no HAVING quantity>0 ORDER BY return_no") + List ownership(@Param("customerId") String customerId, @Param("mark") String mark, @Param("sku") String sku); + + @Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id") + List listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo); + + @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC") + List listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java index 7811d8d63..99ed4cff6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java @@ -3,12 +3,12 @@ package com.baiyue.lastleg.returnreceipt.service; import com.google.zxing.oned.Code128Writer; /** Inline vector bars remain printable even when browser background printing is disabled. */ -final class ReturnCartonBarcode { +public final class ReturnCartonBarcode { private static final int QUIET_ZONE = 10; private ReturnCartonBarcode() { } - static String svg(String cartonMark) { + public static String svg(String cartonMark) { boolean[] modules = new Code128Writer().encode(cartonMark); int width = modules.length + QUIET_ZONE * 2; StringBuilder svg = new StringBuilder("() { }); + } + if (!AVAILABLE.equals(carton.getStatus()) || cartonItems.lockCarton(carton.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0)) + throw conflict("退货箱已被占用或不可上架"); + var location = warehouseResources.resolveLocation(identity.overseasWarehouseId(), required(request.locationCode(), "库位码不能为空")); + if (location == null || !List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status()) + || "VIRTUAL".equalsIgnoreCase(location.locationType())) throw conflict("目标库位不存在、已停用或为虚拟收货位"); + String palletCode = text(request.palletCode()); + if (palletCode != null) { + var pallet = physicalPallets.resolve(palletCode); + if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId() + || !List.of("IDLE", "OCCUPIED").contains(pallet.status()) || pallet.locationId() == null || pallet.locationId() != location.locationId()) + throw conflict("托盘不在目标库位或不可使用,请先完成托盘移库"); + palletCode = pallet.palletCode(); + } + carton.setLocationCode(location.locationCode()); carton.setPalletCode(palletCode); + carton.setPutawayAt(LocalDateTime.now()); carton.setUpdatedAt(carton.getPutawayAt()); cartons.updateById(carton); + ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); + operation.setOperatorId(identity.operatorKey()); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest); + var result = new ReturnInventoryResponses.PutawayResult(carton.getCartonMark(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt()); + operation.setOperationType("PUTAWAY"); operation.setResultCartonId(carton.getId()); operation.setResultJson(json(result)); operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation); + return result; + } + + @Transactional + public ReturnInventoryResponses.PackingPlan createPackingPlan(String operator, ReturnInventoryRequests.PackingPlanCreate request) { + return createPackingPlan(operator, null, request); + } + + @Transactional + public ReturnInventoryResponses.PackingPlan createPackingPlan(String operator, String customerId, ReturnInventoryRequests.PackingPlanCreate request) { + List returnNos = request.returnNos().stream().map(this::requiredReturnNo).distinct().toList(); + List selected = returnNos.stream().sorted().map(orders::lockNo).filter(java.util.Objects::nonNull).toList(); + if (selected.size() != returnNos.size()) throw notFound("存在不存在的退货单"); + ReturnOrder first = selected.get(0); + if (customerId != null && !customerId.equals(first.getCustomerId())) throw notFound("退货单不存在"); + if (!"COMPLETED".equals(first.getStatus())) throw conflict("只能为已完成入库的退货单创建合箱计划"); + if (selected.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()) || !first.getCustomerId().equals(order.getCustomerId()) || !first.getOverseasWarehouseId().equals(order.getOverseasWarehouseId()))) throw conflict("订单必须属于同一客户、同一海外仓且已完成入库"); + ReturnPackingPlan replay = packingPlans.lockCustomerKey(first.getCustomerId(), request.idempotencyKey()); + if (replay != null) { + if (!hash(request).equals(replay.getRequestHash())) throw conflict("幂等键已用于不同的合箱计划"); + return packingPlan(replay); + } + String mode = required(request.mode(), "合箱方式不能为空").toUpperCase(java.util.Locale.ROOT); + if (!List.of("BOX", "SKU").contains(mode)) throw badRequest("合箱方式只能选择 BOX 或 SKU"); + List marks = request.sourceCartonMarks() == null ? List.of() : request.sourceCartonMarks().stream().distinct().sorted().toList(); + if ("BOX".equals(mode) && marks.isEmpty()) throw badRequest("按箱合箱必须选择来源箱唛"); + if ("SKU".equals(mode) && !marks.isEmpty()) throw badRequest("按 SKU 合箱不能同时选择来源箱"); + Map requested = planQuantities(request.boxes()); + List allocation = new ArrayList<>(); + List sources = "BOX".equals(mode) + ? marks.stream().map(cartons::lockMark).toList() + : packingSourceCartons(selected).stream().filter(c -> "PARCEL".equals(c.getSourceUnitType())) + .sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList(); + Map remaining = new LinkedHashMap<>(requested); + for (ReturnInventoryCarton carton : sources) { + if (carton == null || !first.getCustomerId().equals(carton.getCustomerId()) + || !first.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !AVAILABLE.equals(carton.getStatus()) + || !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用"); + if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择"); + for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) { + long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); + long take = "BOX".equals(mode) ? free : Math.min(free, value(remaining.get(item.getSkuCode()))); + if ("BOX".equals(mode) && value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已有合箱预占"); + if (take <= 0) continue; + if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take); + item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) + take); + cartonItems.updateById(item); + allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take)); + } + if ("BOX".equals(mode)) { carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); } + } + if (allocation.isEmpty() || ("SKU".equals(mode) && remaining.values().stream().anyMatch(q -> q > 0))) throw conflict("来源库存不足,无法锁定合箱数量"); + Map reserved = sourceQuantities(allocation); + for (Map.Entry entry : reserved.entrySet()) { + if (balances.reserveAvailable(first.getCustomerId(), first.getOverseasWarehouseId(), entry.getKey(), entry.getValue()) != 1) + throw conflict("SKU 可用库存不足,无法锁定合箱数量: " + entry.getKey()); + } + List boxes = List.of(new ReturnInventoryRequests.PlanBox( + reserved.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(), e.getValue())).toList(), null, null, null, null)); + ReturnPackingPlan plan = new ReturnPackingPlan(); + plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN", first.getOverseasWarehouseId(), plan.getId())); + plan.setCustomerId(first.getCustomerId()); plan.setOverseasWarehouseId(first.getOverseasWarehouseId()); + plan.setReturnNosJson(json(returnNos)); plan.setBoxesJson(json(boxes)); plan.setSourceItemsJson(json(allocation)); + plan.setMode(mode); plan.setStatus("PENDING_REVIEW"); plan.setRemark(request.remark()); plan.setIdempotencyKey(request.idempotencyKey()); + plan.setRequestHash(hash(request)); + plan.setCreatedAt(LocalDateTime.now()); plan.setUpdatedAt(plan.getCreatedAt()); packingPlans.insert(plan); + return packingPlan(plan); + } + + private record SourceItem(String cartonMark, String skuCode, long quantity) { } + + @Transactional(readOnly = true) + public List packingHistory(String customerId, String returnNo) { + return packingTrace.listReturn(customerId, returnNo).stream().filter(t -> StringUtils.hasText(t.sourceCartonMark())) + .map(t -> new ReturnOrderResponses.PackingDetail(t.sourceCartonMark(), t.targetCartonMark(), t.returnNo(), t.skuCode(), t.quantity(), t.createdAt())).toList(); + } + + @Transactional + public ReturnInventoryResponses.PackingPlan cancelPackingPlan(String planNo) { + ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空")); + if (plan == null) throw notFound("合箱计划不存在"); + if ("CANCELLED".equals(plan.getStatus())) return packingPlan(plan); + if (!List.of("PENDING_REVIEW", "READY").contains(plan.getStatus())) throw conflict("当前计划不能取消"); + List allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { }); + for (SourceItem source : allocation.stream().sorted(Comparator.comparing(SourceItem::cartonMark)).toList()) { + ReturnInventoryCarton carton = cartons.lockMark(source.cartonMark()); + if (carton == null || !plan.getCustomerId().equals(carton.getCustomerId())) throw conflict("计划来源箱异常"); + var item = cartonItems.lockSku(carton.getId(), source.skuCode()); + if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("计划预占数量异常"); + item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); cartonItems.updateById(item); + if ("PACKING_RESERVED".equals(carton.getStatus())) { carton.setStatus(AVAILABLE); cartons.updateById(carton); } + } + for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(), plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0); + plan.setStatus("CANCELLED"); plan.setUpdatedAt(LocalDateTime.now()); packingPlans.updateById(plan); + return packingPlan(plan); + } + + private List packingSourceCartons(List selected) { + List result = new ArrayList<>(); + for (ReturnOrder order : selected) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) + result.addAll(cartons.listReceipt(receipt.getId())); + // Merged cartons retain the actual source return numbers, even without a receipt ID. + for (ReturnOrder order : selected) result.addAll(cartons.packingAvailable(order.getCustomerId(), order.getOverseasWarehouseId()) + .stream().filter(c -> sourceMatches(c, selected.stream().map(ReturnOrder::getReturnNo).toList())).toList()); + return new ArrayList<>(result.stream().collect(java.util.stream.Collectors.toMap( + ReturnInventoryCarton::getCartonMark, c -> c, (a, b) -> a, LinkedHashMap::new)).values()); + } + + private boolean sourceMatches(ReturnInventoryCarton carton, List returnNos) { + if (text(carton.getSourceReturnNo()) == null) return false; + return java.util.Arrays.stream(carton.getSourceReturnNo().split(",")).allMatch(returnNos::contains); + } + + private Map planQuantities(List boxes) { + Map result = new LinkedHashMap<>(); + for (var box : boxes) if (box.items() != null) for (var item : box.items()) { + if (item.quantity() <= 0) throw badRequest("合箱数量必须大于 0"); + result.merge(required(item.skuCode(), "SKU 不能为空"), item.quantity(), Math::addExact); + } + return result; + } + + private Map sourceQuantities(List items) { + Map result = new LinkedHashMap<>(); + for (var item : items) result.merge(item.skuCode(), item.quantity(), Math::addExact); + return result; + } + @Transactional(readOnly = true) + public PageResult customerPackingPlans(String customerId, ReturnInventoryRequests.PackingPlanQuery request) { + String status = text(request.status()); + long total = packingPlans.countCustomer(customerId, status); + List records = total == 0 ? List.of() : packingPlans.listCustomer(customerId, status, + (request.page() - 1) * request.size(), request.size()).stream().map(this::packingPlan).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult platformPackingPlans(ReturnInventoryRequests.PackingPlanQuery request) { + String status = text(request.status()); + String planNo = text(request.planNo()); + String customerId = text(request.customerId()), warehouseId = text(request.overseasWarehouseId()), sku = text(request.skuCode()); + long total = packingPlans.countPlatform(status, planNo, customerId, warehouseId, sku); + List records = total == 0 ? List.of() : packingPlans.listPlatform(status, planNo, customerId, warehouseId, sku, + (request.page() - 1) * request.size(), request.size()).stream().map(this::packingPlan).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional + public ReturnInventoryResponses.PackingPlan reviewPackingPlanPlatform(String planNo) { + ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空")); + if (plan == null) throw notFound("合箱计划不存在"); + if (!"PENDING_REVIEW".equals(plan.getStatus())) throw conflict("当前合箱计划不在待审核状态"); + LocalDateTime now = LocalDateTime.now(); plan.setStatus("READY"); plan.setPushedAt(now); plan.setUpdatedAt(now); packingPlans.updateById(plan); + return packingPlan(plan); + } + + @Transactional(readOnly = true) + public List listPackingPlans(PdaWarehouseIdentity identity) { + return packingPlans.listPending(String.valueOf(identity.overseasWarehouseId())).stream().map(this::packingPlan).toList(); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.PackingPlan packingPlan(PdaWarehouseIdentity identity, String planNo) { + ReturnPackingPlan plan = packingPlans.findNo(required(planNo, "合箱计划号不能为空")); + if (plan == null || plan.getOverseasWarehouseId() == null || !plan.getOverseasWarehouseId().equals(String.valueOf(identity.overseasWarehouseId()))) throw notFound("合箱计划不存在"); + return packingPlan(plan); + } + + @Transactional + public ReturnInventoryResponses.PackingPlanCompletion completePackingPlan(PdaWarehouseIdentity identity, String planNo, ReturnInventoryRequests.PackingPlanComplete request) { + ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空")); + if (plan == null || !String.valueOf(identity.overseasWarehouseId()).equals(plan.getOverseasWarehouseId())) throw notFound("合箱计划不存在"); + if ("COMPLETED".equals(plan.getStatus())) { + if (!hash(request).equals(plan.getCompletionHash())) throw conflict("已完成计划不能使用不同的箱明细重复提交"); + return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), read(plan.getResultLabelsJson(), new TypeReference>() { })); + } + if (!"READY".equals(plan.getStatus())) throw conflict("合箱计划尚未审核或已取消"); + if ("BOX".equals(plan.getMode()) && request.boxes().size() != 1) throw badRequest("箱与箱合并必须扫描一个已有目标箱,不支持拆分来源箱"); + List allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { }); + if (allocation.isEmpty()) throw conflict("合箱计划缺少来源锁定明细,请重新创建"); + String plannedTarget = "BOX".equals(plan.getMode()) && request.boxes().size() == 1 + ? text(request.boxes().get(0).targetCartonMark()) : null; + List sourceAllocation = plannedTarget == null ? allocation : allocation.stream() + .filter(source -> !plannedTarget.equals(source.cartonMark())).toList(); + if ("BOX".equals(plan.getMode()) && request.sourceDeductions() != null) { + Map seen = new LinkedHashMap<>(); + List selected = new ArrayList<>(); + for (var deduction : request.sourceDeductions()) { + if (deduction.quantity() <= 0 || Objects.equals(plannedTarget, deduction.cartonMark())) throw badRequest("不能扣除目标箱库存"); + long maximum = sourceAllocation.stream().filter(s -> s.cartonMark().equals(deduction.cartonMark()) && s.skuCode().equals(deduction.skuCode())).mapToLong(SourceItem::quantity).sum(); + if (deduction.quantity() > maximum || seen.put(deduction.cartonMark() + ":" + deduction.skuCode(), deduction.quantity()) != null) throw conflict("来源箱 SKU 扣除数量超过计划或重复"); + selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity())); + } + sourceAllocation = selected; + } + final List selectedAllocation = sourceAllocation; + if (!sourceQuantities(sourceAllocation).equals(planQuantities(request.boxes()))) throw conflict("来源箱 SKU 数量合计必须与计划锁定数量完全一致"); + List returnNos = read(plan.getReturnNosJson(), new TypeReference<>() { }); + Map> limits = new LinkedHashMap<>(); + for (SourceItem source : allocation.stream().filter(source -> !Objects.equals(plannedTarget, source.cartonMark())).sorted(Comparator.comparing(SourceItem::cartonMark)).toList()) { + ReturnInventoryCarton carton = cartons.lockMark(source.cartonMark()); + if (carton == null || !plan.getCustomerId().equals(carton.getCustomerId()) + || !List.of(AVAILABLE, "PACKING_RESERVED").contains(carton.getStatus())) throw conflict("计划来源箱状态异常"); + ReturnInventoryCartonItem item = cartonItems.lockSku(carton.getId(), source.skuCode()); + if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("计划库存预占丢失"); + item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); + cartonItems.updateById(item); + if ("PACKING_RESERVED".equals(carton.getStatus())) { carton.setStatus(AVAILABLE); cartons.updateById(carton); } + long selected = selectedAllocation.stream().filter(s -> s.cartonMark().equals(source.cartonMark()) && s.skuCode().equals(source.skuCode())).mapToLong(SourceItem::quantity).sum(); + if (selected > 0) limits.computeIfAbsent(source.cartonMark(), ignored -> new LinkedHashMap<>()).merge(source.skuCode(), selected, Math::addExact); + } + if (plannedTarget != null) { + ReturnInventoryCarton targetSource = cartons.lockMark(plannedTarget); + if (targetSource == null || !plan.getCustomerId().equals(targetSource.getCustomerId()) + || !List.of(AVAILABLE, "PACKING_RESERVED").contains(targetSource.getStatus())) throw conflict("目标原箱状态异常"); + for (SourceItem source : allocation.stream().filter(item -> plannedTarget.equals(item.cartonMark())).toList()) { + ReturnInventoryCartonItem item = cartonItems.lockSku(targetSource.getId(), source.skuCode()); + if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("目标原箱库存预占丢失"); + item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); cartonItems.updateById(item); + } + if ("PACKING_RESERVED".equals(targetSource.getStatus())) { targetSource.setStatus(AVAILABLE); cartons.updateById(targetSource); } + } + for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(), + plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0); + List labels = new ArrayList<>(); + List generatedMarks = "SKU".equals(plan.getMode()) || plannedTarget == null ? packingMarks(request.boxes()) : Collections.nCopies(request.boxes().size(), null); + int index = 0; + for (ReturnInventoryRequests.PlanBox box : request.boxes()) { + if (box.items() == null || box.items().isEmpty()) throw badRequest("每个目标箱至少填写一条 SKU"); + if (( !"BOX".equals(plan.getMode()) || plannedTarget == null) && (box.length() == null || box.width() == null || box.height() == null || box.weight() == null + || box.length().signum() <= 0 || box.width().signum() <= 0 || box.height().signum() <= 0 || box.weight().signum() <= 0)) + throw badRequest("PDA 完成合箱时必须填写有效箱规"); + List items = box.items().stream().map(item -> new ReturnInventoryRequests.ValidItem(item.skuCode(), item.quantity())).toList(); + var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(returnNos, items, box.length(), box.width(), box.height(), box.weight(), + request.idempotencyKey() + "-" + index), limits, + "BOX".equals(plan.getMode()) ? text(box.targetCartonMark()) : null, generatedMarks.get(index++)); + labels.add(result.label()); + ReturnInventoryOperation completedOperation = operations.lockKey(identity.operatorKey(), request.idempotencyKey() + "-" + (index - 1)); + if (completedOperation != null) { + completedOperation.setResultJson(json(new ReturnInventoryResponses.MergeResult(result.cartonMark(), result.skuCode(), result.totalQuantity(), result.label(), plan.getPlanNo()))); + operations.updateById(completedOperation); + } + } + LocalDateTime now = LocalDateTime.now(); plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); plan.setCompletedAt(now); + plan.setBoxesJson(json(request.boxes())); plan.setResultMarksJson(json(labels.stream().map(ReturnInventoryResponses.Label::cartonMark).toList())); + plan.setCompletionHash(hash(request)); + plan.setResultLabelsJson(json(labels)); + packingPlans.updateById(plan); + return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), labels); + } + + private ReturnInventoryResponses.PackingPlan packingPlan(ReturnPackingPlan plan) { + List sources = read(plan.getSourceItemsJson(), new TypeReference<>() { }); + List boxes = read(plan.getBoxesJson(), new TypeReference<>() { }); + List responseBoxes = new ArrayList<>(); + for (int i = 0; i < boxes.size(); i++) { + var box = boxes.get(i); + responseBoxes.add(new ReturnInventoryResponses.PlanBox(i + 1, box.items() == null ? List.of() : box.items().stream() + .map(item -> new ReturnInventoryResponses.PlanItem(item.skuCode(), item.quantity())).toList(), box.length(), box.width(), box.height(), box.weight())); + } + return new ReturnInventoryResponses.PackingPlan(plan.getPlanNo(), plan.getCustomerId(), plan.getOverseasWarehouseId(), + read(plan.getReturnNosJson(), new TypeReference<>() { }), plan.getMode(), plan.getStatus(), responseBoxes, plan.getRemark(), plan.getPushedAt(), + sources.stream().map(SourceItem::cartonMark).distinct().toList(), + sources.stream().map(SourceItem::cartonMark).distinct().map(mark -> new ReturnInventoryResponses.PlanSource(mark, + sources.stream().filter(source -> source.cartonMark().equals(mark)).map(source -> new ReturnInventoryResponses.PlanItem(source.skuCode(), source.quantity())).toList())).toList(), + plan.getCreatedAt(), "COMPLETED".equals(plan.getStatus()) + ? read(plan.getResultLabelsJson(), new TypeReference>() { }).stream() + .map(label -> cartons.findCustomerMark(plan.getCustomerId(), label.cartonMark())) + .filter(java.util.Objects::nonNull).map(this::cartonDetail).toList() : List.of(), plan.getCompletedAt()); + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception error) { throw new IllegalStateException("合箱计划序列化失败", error); } } + private T read(String value, TypeReference type) { try { return objectMapper.readValue(value == null ? "[]" : value, type); } catch (Exception error) { throw new IllegalStateException("合箱计划数据损坏", error); } } /** Called inside the PDA pick transaction; carton contents remain the physical shipment snapshot. */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @@ -66,14 +383,15 @@ public class ReturnInventoryService { String outboundNo, long outboundPickId) { ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "退货箱码不能为空")); if (carton == null || !customerId.equals(carton.getCustomerId()) - || !warehouseId.equals(carton.getOverseasWarehouseId()) || !returnNo.equals(carton.getSourceReturnNo()) - || !AVAILABLE.equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配"); + || !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo) + || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配"); List contents = cartonItems.lockCarton(carton.getId()); long total = 0; var skus = new java.util.HashSet(); for (ReturnInventoryCartonItem content : contents) { if (!customerId.equals(content.getCustomerId()) || !carton.getId().equals(content.getCartonId()) - || text(content.getSkuCode()) == null || !skus.add(content.getSkuCode()) || value(content.getQuantity()) <= 0) { + || text(content.getSkuCode()) == null || !skus.add(content.getSkuCode()) || value(content.getQuantity()) <= 0 + || value(content.getPackingReservedQuantity()) > 0) { throw conflict("退货箱内容缺失或归属、SKU数量不一致"); } total = Math.addExact(total, content.getQuantity()); @@ -117,8 +435,7 @@ public class ReturnInventoryService { public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, String requestedMark, List lines) { if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空"); - if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU"); - String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空"); + String normalizedUnitCode = required(receipt.getUnitCode(), "实物单元编码不能为空"); required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); @@ -130,7 +447,13 @@ public class ReturnInventoryService { carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId()); carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); - carton.setPalletCode(normalizedPalletCode); + carton.setPalletCode(text(receipt.getPalletCode())); + carton.setSourceUnitType(receipt.getUnitType()); + carton.setSourceUnitCode(normalizedUnitCode); + // Pallet measurements describe the carrier, never each child carton. + if ("BOX".equals(receipt.getUnitType())) { + carton.setLength(receipt.getLength()); carton.setWidth(receipt.getWidth()); carton.setHeight(receipt.getHeight()); carton.setWeight(receipt.getWeight()); + } carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); cartons.insert(carton); for (ReceiptLine line : lines) { @@ -144,6 +467,33 @@ public class ReturnInventoryService { return carton; } + /** 托盘仅作为载体;按 SKU 箱数生成独立的退货库存箱。 */ + @Transactional + public List createReceiptCartons(ReturnOrder order, ReturnReceipt receipt, + List lines, + Map boxCounts) { + List result = new ArrayList<>(); + for (ReceiptLine line : lines) { + long boxes = boxCounts.getOrDefault(line.skuCode(), 0L); + if (boxes <= 0) throw badRequest("托盘签收的 SKU 箱数必须大于 0: " + line.skuCode()); + long base = line.quantity() / boxes; + long remainder = line.quantity() % boxes; + for (long index = 0; index < boxes; index++) { + long quantity = base + (index < remainder ? 1 : 0); + if (quantity <= 0) throw badRequest("SKU 数量不足以分配到箱: " + line.skuCode()); + String mark = shortReturnMark(order.getReturnNo(), receipt.getId(), line.skuCode(), boxes, index + 1); + result.add(createReceiptCarton(order, receipt, mark, + List.of(new ReceiptLine(line.skuCode(), line.productNameZh(), quantity)))); + } + } + return result; + } + + private String shortReturnMark(String returnNo, Long receiptId, String skuCode, long totalBoxes, long sequence) { + String prefix = hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16).toUpperCase(java.util.Locale.ROOT); + return "RC" + prefix + "#" + totalBoxes + "-" + sequence; + } + @Transactional(readOnly = true) public PageResult page(String customerId, ReturnInventoryRequests.Query request) { String warehouseId = text(request.overseasWarehouseId()); @@ -185,7 +535,8 @@ public class ReturnInventoryService { List records = total == 0 ? List.of() : balances.platformPage(customerId, warehouseId, sku, (request.page() - 1) * request.size(), request.size()).stream().map(balance -> new ReturnInventoryResponses.PlatformStock( balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(), - value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt())).toList(); + value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt(), + cartonItems.looseAvailableQuantity(balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode()))).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -205,9 +556,9 @@ public class ReturnInventoryService { long total = cartons.platformCount(customerId, warehouseId, cartonMark, returnNo, status, sku); List records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark, returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton( - carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceBatchNo(), + carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(), carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), - carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); + carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -218,6 +569,24 @@ public class ReturnInventoryService { return cartonDetail(carton); } + @Transactional(readOnly = true) + public PageResult platformPackingSourcePage(ReturnInventoryRequests.PlatformQuery request) { + String customerId = required(request.customerId(), "客户不能为空"); + String warehouseId = required(request.overseasWarehouseId(), "海外仓不能为空"); + long total = cartons.platformCount(customerId, warehouseId, null, null, AVAILABLE, null); + List page = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, + null, null, AVAILABLE, null, (request.page() - 1) * request.size(), request.size()); + java.util.Map> itemsByCarton = page.isEmpty() ? java.util.Map.of() : + cartonItems.listCartons(page.stream().map(ReturnInventoryCarton::getId).toList()).stream() + .collect(java.util.stream.Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId)); + List records = page.stream().map(carton -> + new ReturnInventoryResponses.PackingSource(carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), + itemsByCarton.getOrDefault(carton.getId(), List.of()).stream().map(item -> + new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()), + Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList())).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + @Transactional(readOnly = true) public ReturnInventoryResponses.Label platformLabel(String cartonMark) { ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空")); @@ -246,33 +615,228 @@ public class ReturnInventoryService { return label(carton); } + /** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */ + @Transactional(readOnly = true) + public List receiptLabels(ReturnOrder order, ReturnReceipt receipt, + List lines) { + List existing = cartons.listReceipt(receipt.getId()).stream() + .filter(carton -> !"MERGED".equals(carton.getStatus())).toList(); + if ("PARCEL".equals(receipt.getUnitType())) return List.of(); + long expected = "BOX".equals(receipt.getUnitType()) ? Math.max(1, value(receipt.getTotalBoxCount())) + : lines.stream().mapToLong(line -> Math.max(0, line.boxCount())).sum(); + if (expected <= 0 || existing.size() >= expected) { + return existing.stream().map(carton -> new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label(order.getCustomerId(), carton.getCartonMark()).printableHtml())).toList(); + } + return List.of(); + } + + @Transactional + public List repairReceiptCartons(String operator, String customerId, + String returnNo, String receiptNo, ReturnInventoryRequests.ReceiptCartonRepair request) { + ReturnOrder order = orders.lockCustomerNo(customerId, required(returnNo, "退货单不能为空")); + if (order == null) throw notFound("退货单不存在"); + ReturnReceipt receipt = receiptMapper.listOrder(order.getId()).stream() + .filter(value -> receiptNo.equals(value.getReceiptNo())).findFirst().orElseThrow(() -> notFound("签收记录不存在")); + String digest = hash(List.of(customerId, returnNo, receiptNo, request)); + ReturnInventoryOperation replay = operations.lockKey(operator, request.idempotencyKey()); + if (replay != null) { + if (!"REPAIR_RECEIPT".equals(replay.getOperationType()) || !digest.equals(replay.getRequestHash())) + throw conflict("幂等键已用于不同的历史箱修复请求"); + return read(replay.getResultJson(), new TypeReference>() { }); + } + var lines = receiptItemMapper.listReceipt(receipt.getId()); + long expected = lines.stream().mapToLong(line -> value(line.getBoxCount())).sum(); + if (!"PALLET".equals(receipt.getUnitType()) || expected <= 1 || request.confirmedBoxCount() != expected) + throw conflict("只支持现场核实总箱数后的历史整托签收修复"); + List original = cartons.listReceipt(receipt.getId()); + if (original.size() != 1) throw conflict("仅可修复旧版一条整托库存记录,其他情况需要逐箱核实"); + ReturnInventoryCarton source = cartons.lockId(original.get(0).getId()); + if (!customerId.equals(source.getCustomerId()) || !returnNo.equals(source.getSourceReturnNo()) + || !AVAILABLE.equals(source.getStatus()) || source.getRelabelApplicationId() != null || source.getPutawayAt() != null) + throw conflict("历史箱已被作业占用或上架,不能自动拆分修复"); + var contents = cartonItems.lockCarton(source.getId()).stream().sorted(Comparator.comparing(ReturnInventoryCartonItem::getSkuCode)).toList(); + var counts = new LinkedHashMap(); + var quantities = new LinkedHashMap(); + for (var line : lines) { counts.merge(line.getSkuCode(), value(line.getBoxCount()), Math::addExact); quantities.merge(line.getSkuCode(), value(line.getReceivedQuantity()), Math::addExact); } + if (contents.size() != counts.size() || value(source.getTotalQuantity()) != value(receipt.getReceivedQuantity())) + throw conflict("历史实收与当前箱内容不一致,需人工核对"); + for (var item : contents) { + long quantity = value(item.getQuantity()), boxes = counts.getOrDefault(item.getSkuCode(), 0L); + if (quantity != quantities.getOrDefault(item.getSkuCode(), 0L) || boxes <= 0 || boxes > quantity + || value(item.getPackingReservedQuantity()) > 0 + || !packingTrace.ownership(customerId, source.getCartonMark(), item.getSkuCode()).isEmpty()) + throw conflict("历史箱SKU数量、箱数或来源已变化,不能自动修复"); + var balance = balances.lockSku(customerId, order.getOverseasWarehouseId(), item.getSkuCode()); + if (balance == null || value(balance.getAvailableQuantity()) < quantity || value(balance.getCartonQuantity()) < 1) + throw conflict("历史库存余额不足,不能自动修复"); + } + long operationId = IdWorker.getId(); + List replacement = contents.stream().map(item -> new ReceiptLine(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); + for (var item : contents) { + balances.adjust(IdWorker.getId(), customerId, order.getOverseasWarehouseId(), item.getSkuCode(), item.getProductNameZh(), -item.getQuantity(), -item.getQuantity(), -1); + item.setQuantity(0L); cartonItems.updateById(item); + } + source.setStatus("MERGED"); source.setTotalQuantity(0L); source.setSkuTypes(0); source.setUpdatedAt(LocalDateTime.now()); cartons.updateById(source); + var generated = createReceiptCartons(order, receipt, replacement, counts); + for (var carton : generated) for (var item : cartonItems.listCarton(carton.getId())) + packingTrace.insert(IdWorker.getId(), customerId, operationId, source.getCartonMark(), carton.getCartonMark(), returnNo, + item.getSkuCode(), item.getQuantity(), LocalDateTime.now()); + receipt.setCartonMark(generated.get(0).getCartonMark()); receiptMapper.updateById(receipt); + List result = generated.stream().map(carton -> new ReturnOrderResponses.CartonLabel( + carton.getCartonMark(), label(customerId, carton.getCartonMark()).printableHtml())).toList(); + ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(operationId); + operation.setOperatorId(operator); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest); + operation.setOperationType("REPAIR_RECEIPT"); operation.setResultCartonId(source.getId()); operation.setResultJson(json(result)); + operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation); + return result; + } + + public boolean receiptRepairRequired(ReturnReceipt receipt, long expected) { + if ("PARCEL".equals(receipt.getUnitType()) || expected <= 0) return false; + // Consumed/merged originals are valid history, not missing physical box records. + return cartons.listReceipt(receipt.getId()).size() < expected; + } + @Transactional(readOnly = true) public ReturnInventoryResponses.PackingPreview packingPreview(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingQuery request) { - List candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo())); + List candidates; + if (text(request.skuCode()) != null) { + List matching = orderItems.completedSkuCandidates(String.valueOf(identity.overseasWarehouseId()), text(request.skuCode())).stream() + .map(item -> orders.selectById(item.getReturnOrderId())).filter(java.util.Objects::nonNull).toList(); + candidates = matching.stream().map(ReturnOrder::getCustomerId).distinct() + .flatMap(customer -> orders.completedCustomer(customer, String.valueOf(identity.overseasWarehouseId())).stream()).toList(); + } else { + candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo())); + } Map unique = new LinkedHashMap<>(); candidates.forEach(order -> unique.put(order.getReturnNo(), order)); - List selected = unique.values().stream().limit(100).toList(); - String customerId = selected.size() == 1 ? selected.get(0).getCustomerId() : null; - Map quantities = new LinkedHashMap<>(); - Map names = new LinkedHashMap<>(); - for (ReturnOrder order : selected) { + List selected = unique.values().stream().toList(); + Map> byCustomer = selected.stream().collect(java.util.stream.Collectors.groupingBy(ReturnOrder::getCustomerId, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List customers = new ArrayList<>(); + for (Map.Entry> customer : byCustomer.entrySet()) { + Map quantities = new LinkedHashMap<>(); Map names = new LinkedHashMap<>(); + LinkedHashSet ordersWithAvailableSku = new LinkedHashSet<>(); + for (ReturnOrder order : customer.getValue()) { for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) { - ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); - if (carton == null || !AVAILABLE.equals(carton.getStatus())) continue; - for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) { - quantities.merge(item.getSkuCode(), value(item.getQuantity()), Math::addExact); - names.putIfAbsent(item.getSkuCode(), item.getProductNameZh()); + for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { + if (!AVAILABLE.equals(carton.getStatus()) || !"PARCEL".equals(carton.getSourceUnitType())) continue; + for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) { + if (request.skuCode() != null && text(request.skuCode()).equals(item.getSkuCode()) && value(item.getQuantity()) - value(item.getPackingReservedQuantity()) > 0) { + ordersWithAvailableSku.add(order.getReturnNo()); + } + quantities.merge(item.getSkuCode(), Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())), Math::addExact); + names.putIfAbsent(item.getSkuCode(), item.getProductNameZh()); + } } } + } + quantities.replaceAll((sku, quantity) -> Math.min(quantity, balances.availableQuantity(customer.getKey(), String.valueOf(identity.overseasWarehouseId()), sku))); + if (text(request.skuCode()) != null && ordersWithAvailableSku.isEmpty()) continue; + List customerOrders = customer.getValue(); + customers.add(new ReturnInventoryResponses.PackingCustomer(customer.getKey(), customerOrders.stream().map(order -> new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), quantities.entrySet().stream().filter(entry -> entry.getValue() > 0).map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList())); } - return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order -> - new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), - customerId, quantities.entrySet().stream().map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList()); + String customerId = customers.size() == 1 ? customers.get(0).customerId() : null; + ReturnInventoryResponses.PackingCustomer first = customers.size() == 1 ? customers.get(0) : null; + return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order -> new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), customerId, first == null ? List.of() : first.skus(), customers); } @Transactional public ReturnInventoryResponses.MergeResult merge(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request) { - String requestHash = hash(request); + return mergeScoped(identity, request, null); + } + + @Transactional + public ReturnInventoryResponses.PackingPlanCompletion pack(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingExecution request) { + String digest = hash(request); + for (String returnNo : request.returnNos().stream().distinct().sorted().toList()) { + var order = orders.lockNo(returnNo); + if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) throw notFound("当前仓没有来源退货单"); + } + ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey()); + if (replay != null && (!"PACK".equals(replay.getOperationType()) || !digest.equals(replay.getRequestHash()))) throw conflict("幂等键已用于不同的多箱合箱请求"); + if (replay != null) return read(replay.getResultJson(), new TypeReference() { }); + List labels = new ArrayList<>(); + List generatedMarks = packingMarks(request.boxes()); + int index = 0; + for (var box : request.boxes()) { + if (text(box.targetCartonMark()) != null) throw badRequest("散 SKU 合箱不能同时指定已有目标箱"); + if (box.items() == null || box.items().isEmpty() || box.length() == null || box.width() == null || box.height() == null || box.weight() == null + || box.length().signum() <= 0 || box.width().signum() <= 0 || box.height().signum() <= 0 || box.weight().signum() <= 0) throw badRequest("每个新箱都必须填写 SKU、数量和有效箱规"); + var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(request.returnNos(), box.items().stream().map(i -> new ReturnInventoryRequests.ValidItem(i.skuCode(), i.quantity())).toList(), + box.length(), box.width(), box.height(), box.weight(), request.idempotencyKey() + ":box:" + index), null, null, generatedMarks.get(index++)); + labels.add(result.label()); + } + if (replay == null) { + ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); + operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest); operation.setOperationType("PACK"); + operation.setResultJson(json(new ReturnInventoryResponses.PackingPlanCompletion(null, labels))); + operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation); + } + return new ReturnInventoryResponses.PackingPlanCompletion(null, labels); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.CartonDetail scanCarton(PdaWarehouseIdentity identity, String mark) { + ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空")); + if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱"); + if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱"); + return cartonDetail(carton); + } + + @Transactional + public ReturnInventoryResponses.MergeResult mergeBoxes(PdaWarehouseIdentity identity, ReturnInventoryRequests.BoxMerge request) { + // Bind replay to the complete scan command before looking up emptied sources. + ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey()); + String digest = hash(request); + if (replay != null) return replayMerge(replay, digest); + java.util.stream.Stream.concat(request.sourceCartonMarks().stream(), java.util.stream.Stream.of(request.targetCartonMark())) + .filter(StringUtils::hasText).distinct().sorted().forEach(cartons::lockMark); + Map> limits = new LinkedHashMap<>(); + Map quantities = new LinkedHashMap<>(); + LinkedHashSet returnNos = new LinkedHashSet<>(); + String customer = null; + for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) { + if (mark.equals(request.targetCartonMark())) throw badRequest("来源箱不能与目标箱相同"); + ReturnInventoryCarton carton = cartons.lockMark(mark); + if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType()) + || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并"); + if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并"); + customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(","))); + Map items = new LinkedHashMap<>(); + for (var item : cartonItems.lockCarton(carton.getId())) { + if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已被合箱计划占用"); + long selected = request.sourceDeductions() == null ? value(item.getQuantity()) : request.sourceDeductions().stream() + .filter(d -> mark.equals(d.cartonMark()) && item.getSkuCode().equals(d.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum(); + if (selected < 0 || selected > value(item.getQuantity())) throw conflict("来源箱 SKU 扣除数量超过库存"); + if (selected > 0) { items.put(item.getSkuCode(), selected); quantities.merge(item.getSkuCode(), selected, Math::addExact); } + } + limits.put(mark, items); + } + if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱"); + String targetMark = text(request.targetCartonMark()); + if(targetMark == null && (request.length()==null || request.width()==null || request.height()==null || request.weight()==null)) throw badRequest("新增目标箱必须填写箱规"); + var planItems=quantities.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(),e.getValue())).toList(); + String newMark=targetMark==null?packingMarks(List.of(new ReturnInventoryRequests.PlanBox(planItems,request.length(),request.width(),request.height(),request.weight()))).get(0):null; + var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(new ArrayList<>(returnNos), quantities.entrySet().stream() + .map(e -> new ReturnInventoryRequests.ValidItem(e.getKey(), e.getValue())).toList(), request.length(), request.width(), request.height(), request.weight(), request.idempotencyKey()), limits, targetMark, newMark); + ReturnInventoryOperation operation = operations.lockKey(identity.operatorKey(), request.idempotencyKey()); + operation.setRequestHash(digest); operations.updateById(operation); + return result; + } + + private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request, + Map> limits) { + return mergeScoped(identity, request, limits, null); + } + + private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request, + Map> limits, String targetMark) { + return mergeScoped(identity, request, limits, targetMark, null); + } + + private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request, + Map> limits, String targetMark, String generatedMark) { + String requestHash = targetMark == null ? hash(request) : hash(List.of(request, targetMark, limits.keySet())); String key = text(request.idempotencyKey()); ReturnInventoryOperation replay = operations.findKey(identity.operatorKey(), key); if (replay != null) return replayMerge(replay, requestHash); @@ -294,20 +858,41 @@ public class ReturnInventoryService { String customerId = selectedOrders.get(0).getCustomerId(); if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单"); if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱"); + ReturnInventoryCarton existingTarget = targetMark == null ? null : cartons.lockMark(targetMark); + if (targetMark != null && (existingTarget == null || !customerId.equals(existingTarget.getCustomerId()) + || !String.valueOf(identity.overseasWarehouseId()).equals(existingTarget.getOverseasWarehouseId()) + || !AVAILABLE.equals(existingTarget.getStatus()) || "PARCEL".equals(existingTarget.getSourceUnitType()) + || limits.containsKey(targetMark))) throw conflict("目标箱不存在、被占用、跨客户或与来源箱相同"); + if (existingTarget != null && cartonItems.lockCarton(existingTarget.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0)) + throw conflict("目标箱已有合箱预占,不能并入"); + if (existingTarget != null) for (var item : cartonItems.lockCarton(existingTarget.getId())) { + long tracked = packingTrace.ownership(customerId, targetMark, item.getSkuCode()).stream() + .mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum(); + if (tracked < value(item.getQuantity())) { + String[] origins = required(existingTarget.getSourceReturnNo(), "目标箱来源退货单缺失").split(","); + if (origins.length != 1) throw conflict("历史目标混箱缺少 SKU 来源明细"); + packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), "", targetMark, + origins[0], item.getSkuCode(), value(item.getQuantity()) - tracked, now); + } + } List sources = new ArrayList<>(); Map sourceOwners = new LinkedHashMap<>(); Map candidateOwners = new java.util.TreeMap<>(); - for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) { - ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); - if (carton != null && AVAILABLE.equals(carton.getStatus())) { - candidateOwners.put(carton.getCartonMark(), order); - } + for (ReturnInventoryCarton carton : packingSourceCartons(selectedOrders)) { + if (!AVAILABLE.equals(carton.getStatus()) || (limits != null && !limits.containsKey(carton.getCartonMark())) + || (limits == null && !"PARCEL".equals(carton.getSourceUnitType()))) continue; + if (!sourceMatches(carton, returnNos)) continue; + ReturnOrder owner = selectedOrders.stream().filter(order -> java.util.Arrays.asList( + carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())).findFirst().orElse(null); + if (owner != null) candidateOwners.put(carton.getCartonMark(), owner); } // All requests acquire source locks in the same order. The earlier // candidate query is only a snapshot: availability must be checked again. for (Map.Entry candidate : candidateOwners.entrySet()) { ReturnInventoryCarton locked = cartons.lockMark(candidate.getKey()); - if (locked != null && AVAILABLE.equals(locked.getStatus())) { + if (locked != null && AVAILABLE.equals(locked.getStatus()) && customerId.equals(locked.getCustomerId()) + && String.valueOf(identity.overseasWarehouseId()).equals(locked.getOverseasWarehouseId()) + && sourceMatches(locked, returnNos) && (limits != null || "PARCEL".equals(locked.getSourceUnitType()))) { sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue()); } } @@ -323,6 +908,7 @@ public class ReturnInventoryService { LinkedHashSet consumedBatchNos = new LinkedHashSet<>(); LinkedHashSet consumedReturnNos = new LinkedHashSet<>(); Map> attachedByOrder = new LinkedHashMap<>(); + List transfers = new ArrayList<>(); long total = 0; for (ReturnInventoryCarton source : sources) { // Read committed quantities under the source lock, never a stale MVCC snapshot. @@ -330,10 +916,20 @@ public class ReturnInventoryService { long sourceTotal = 0; int sourceTypes = 0; for (ReturnInventoryCartonItem item : sourceItems) { String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT); - long take = Math.min(value(remaining.get(sku)), value(item.getQuantity())); + long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); + long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku))); + long take = Math.min(value(remaining.get(sku)), allowed); if (take > 0) { + if (limits != null) limits.get(source.getCartonMark()).put(sku, allowed - take); consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱")); - consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱")); + consumedReturnNos.addAll(java.util.Arrays.asList(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱").split(","))); + long toTrace = take; + for (var origin : ownership(source, sku, value(item.getQuantity()))) { + long traced = Math.min(toTrace, origin.quantity()); + if (traced > 0) transfers.add(new TraceTransfer(source.getCartonMark(), origin.returnNo(), sku, traced)); + toTrace -= traced; + } + if (toTrace != 0) throw conflict("来源箱 SKU 的退货单追溯数量不足"); remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take); ReturnOrder owner = sourceOwners.get(source.getId()); if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>()) @@ -352,67 +948,77 @@ public class ReturnInventoryService { } if (remaining.values().stream().anyMatch(value -> value > 0)) throw conflict("合箱数量超过对应退货单的可用库存"); if (total <= 0) throw badRequest("至少填写一个大于 0 的 SKU 合箱数量"); - long targetId = IdWorker.getId(); - ReturnInventoryCarton target = new ReturnInventoryCarton(); - // Stable across transaction rollback/retry; a generated target ID would leak a new resource on every retry. - String palletBusinessKey = "RETURN-MERGE:" + hash(List.of(identity.operatorKey(), key, requestHash)); - var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(), palletBusinessKey); - if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId() - || pallet.palletId() <= 0 || text(pallet.palletCode()) == null) { - throw conflict("合箱目标托盘资源无效,请核对后重试"); - } - target.setPalletCode(pallet.palletCode()); + long targetId = existingTarget == null ? IdWorker.getId() : existingTarget.getId(); + ReturnInventoryCarton target = existingTarget == null ? new ReturnInventoryCarton() : existingTarget; + // A merged carton may remain loose; pallet binding is an explicit warehouse operation. + target.setSourceUnitType("BOX"); target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId()); - target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE); - target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target); - for (Map.Entry entry : requested.entrySet()) { - ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem(); - targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId); targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey()); - targetItem.setProductNameZh(productNames.get(entry.getKey())); targetItem.setQuantity(entry.getValue()); cartonItems.insert(targetItem); - // A SKU's carton count is the number of available physical cartons containing it: - // remove fully emptied source cartons, then add the one merged target carton. - long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0); - if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) { - throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey()); - } + if (existingTarget == null) target.setCartonMark(generatedMark == null ? numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId) : generatedMark); + if (existingTarget != null) { + if (text(target.getSourceBatchNo()) != null) consumedBatchNos.addAll(java.util.Arrays.asList(target.getSourceBatchNo().split(","))); + if (text(target.getSourceReturnNo()) != null) consumedReturnNos.addAll(java.util.Arrays.asList(target.getSourceReturnNo().split(","))); } - attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now); - operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation); - return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target)); + target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE); + if (existingTarget == null) { + target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); + target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target); + } + for (Map.Entry entry : requested.entrySet()) { + ReturnInventoryCartonItem targetItem = existingTarget == null ? null : cartonItems.lockSku(targetId, entry.getKey()); + boolean addedSku = targetItem == null; + if (targetItem == null) { + targetItem = new ReturnInventoryCartonItem(); targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId); + targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey()); targetItem.setProductNameZh(productNames.get(entry.getKey())); + targetItem.setQuantity(entry.getValue()); targetItem.setPackingReservedQuantity(0L); cartonItems.insert(targetItem); + } else { targetItem.setQuantity(Math.addExact(value(targetItem.getQuantity()), entry.getValue())); cartonItems.updateById(targetItem); } + long cartonDelta = (addedSku ? 1L : 0L) - emptiedBySku.getOrDefault(entry.getKey(), 0); + if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) + throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey()); + } + if (existingTarget != null) { + target.setTotalQuantity(cartonItems.totalQuantity(targetId)); target.setSkuTypes(cartonItems.skuTypes(targetId)); + target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId); + } + for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), + transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now); + var result = new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), value(target.getTotalQuantity()), label(target)); + operation.setResultCartonId(targetId); operation.setResultJson(json(result)); operation.setUpdatedAt(now); operations.updateById(operation); + return result; } - private void attachMergedCarton(ReturnInventoryCarton target, Map> attachedByOrder, - List selectedOrders, Map productNames, LocalDateTime now) { - Map byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value)); - for (Map.Entry> orderEntry : attachedByOrder.entrySet()) { - ReturnOrder returnOrder = byId.get(orderEntry.getKey()); - if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue; - LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId()); - if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue; - long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum(); - boolean firstSku = true; - for (Map.Entry skuEntry : orderEntry.getValue().entrySet()) { - LastlegOrderDetail detail = new LastlegOrderDetail(); - detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId()); - detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey()); - detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey())); - detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark()); - detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName()); - lastlegOrderDetails.insert(detail); firstSku = false; - } - tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1)); - tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity))); - tail.setUpdatedAt(now); lastlegOrders.updateById(tail); + private record TraceTransfer(String source, String returnNo, String sku, long quantity) { } + + /** Source numbers on a merged carton are a set of original return orders. */ + public boolean matchesReturnSource(ReturnInventoryCarton carton, String returnNo) { + if (carton == null || text(returnNo) == null || text(carton.getSourceReturnNo()) == null) return false; + var actual = java.util.Arrays.stream(carton.getSourceReturnNo().split(",")).map(String::trim) + .filter(value -> !value.isEmpty()).collect(java.util.stream.Collectors.toSet()); + var requested = java.util.Arrays.stream(returnNo.split(",")).map(String::trim) + .filter(value -> !value.isEmpty()).collect(java.util.stream.Collectors.toSet()); + return !requested.isEmpty() && actual.containsAll(requested); + } + + private List ownership( + ReturnInventoryCarton carton, String sku, long quantity) { + var result = new ArrayList<>(packingTrace.ownership(carton.getCustomerId(), carton.getCartonMark(), sku)); + long traced = result.stream().mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum(); + if (traced < quantity) { + String[] origins = required(carton.getSourceReturnNo(), "缺少来源退货单").split(","); + if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并"); + result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced)); } + return result; } private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求"); + if (text(operation.getResultJson()) != null) return read(operation.getResultJson(), new TypeReference() { }); if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理"); // The result and its label must also bypass the snapshot established before the insert race. ReturnInventoryCarton carton = cartons.lockId(operation.getResultCartonId()); if (carton == null) throw new IllegalStateException("合箱结果箱不存在"); List items = cartonItems.lockCarton(carton.getId()); + if (items.isEmpty()) throw conflict("历史合箱结果已被后续操作消耗,无法重放旧标签"); return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton, items)); } @@ -422,7 +1028,8 @@ public class ReturnInventoryService { private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton, List cartonLines) { List items = cartonLines.stream() - .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); + .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(), + Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList(); return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items)); } @@ -440,24 +1047,35 @@ public class ReturnInventoryService { private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) { List items = cartonItems.listCarton(carton.getId()).stream() - .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); + .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(), + "AVAILABLE".equals(carton.getStatus()) + ? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList(); return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), - carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items); + carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark())); } private static String printableHtml(ReturnInventoryCarton carton, List items) { StringBuilder rows = new StringBuilder(); - for (ReturnInventoryResponses.CartonItem item : items) rows.append("
").append(escape(item.skuCode())).append("") - .append(escape(item.productNameZh())).append("").append(item.quantity()).append("
"); + for (ReturnInventoryResponses.CartonItem item : items) { + rows.append("
") + .append(escape(item.skuCode())) + .append("
"); + } return "退货箱唛
BAIYUE OVERSEAS WAREHOUSE
RETURN CARTON LABEL / 退货箱唛
" - + escape(carton.getCartonMark()) + "
" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
" - + "
来源退货单:" + escape(carton.getSourceReturnNo()) - + "
箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg" - + "
签收批次:" + escape(carton.getSourceBatchNo()) + "
" - + rows + "
SKU品名数量
合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU
"; + + "@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{width:84mm;height:84mm;padding:4mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:700 14px Arial,sans-serif;line-height:1.25;overflow-wrap:anywhere;margin:0 0 5mm}.barcode{width:100%;max-width:76mm;margin:0 0 4mm}.barcode svg{display:block;width:100%;height:auto}.line{display:flex;justify-content:center;align-items:baseline;width:100%;padding:1mm 0;font-size:12px}.sku{max-width:76mm;overflow-wrap:anywhere;font-weight:700}
" + + escape(carton.getCartonMark()) + "
" + + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
" + rows + + "
"; } private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); } + private List packingMarks(List boxes) { + List> groups = boxes.stream().map(box -> box.items() == null ? List.of() : box.items().stream() + .map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList(); + Map, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting())); + Map, String> prefixes = new HashMap<>(); Map, Long> sequences = new HashMap<>(); + return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> "RC" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT)) + + "#" + totals.get(group) + "-" + sequences.merge(group, 1L, Long::sum)).toList(); + } private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); } catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } } @@ -472,4 +1090,5 @@ public class ReturnInventoryService { private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } public record ReceiptLine(String skuCode, String productNameZh, long quantity) { } + public record ReturnOrderLine(String skuCode, String productNameZh, long quantity, long boxCount) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 858536260..bf5b4e3ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -31,6 +31,8 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -69,6 +71,7 @@ public class ReturnReceiptService { @Resource private LastlegOrderNoGenerator numbers; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderDetailMapper lastlegOrderDetails; @Resource private ReturnInventoryCartonMapper cartons; @Resource private ReturnInventoryCartonItemMapper cartonItems; @Resource private RelabelApplicationMapper relabelApplications; @@ -192,6 +195,26 @@ public class ReturnReceiptService { result.getSize(), result.getTotal(), result.getPages()); } + /** Backfill tail-leg cargo for receipts created before tail synchronization was added. */ + @Transactional + public void ensureTailCargo(String customerId, long tailOrderId) { + if (lastlegOrderDetails == null) return; + long existing = lastlegOrderDetails.selectCount(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, tailOrderId)); + if (existing > 0) return; + ReturnOrder order = orders.selectOne(new LambdaQueryWrapper() + .eq(ReturnOrder::getCustomerId, customerId).eq(ReturnOrder::getLastlegOrderId, tailOrderId)); + if (order == null) return; + Map bySku = orderItems.listOrder(order.getId()).stream() + .collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new)); + for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { + List allocations = receiptItems.listReceipt(receipt.getId()).stream() + .map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity()))) + .filter(item -> item.item() != null).toList(); + if (!allocations.isEmpty()) syncTailCargo(order, receipt, allocations, receipt.getSignedAt() == null ? LocalDateTime.now() : receipt.getSignedAt()); + } + } + @Transactional(readOnly = true) public ReturnOrderResponses.Detail detail(String customerId, String returnNo) { ReturnOrder order = orders.findNo(customerId, text(returnNo)); @@ -199,26 +222,74 @@ public class ReturnReceiptService { List orderLines = orderItems.listOrder(order.getId()); List items = orderLines.stream().map(this::item).toList(); List orderReceipts = receipts.listOrder(order.getId()); - List receiptViews = orderReceipts.stream().map(receipt -> - new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(), - receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList(); - Map> palletItems = new LinkedHashMap<>(); Map productNames = new LinkedHashMap<>(); for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh()); + List receiptViews = orderReceipts.stream().map(receipt -> { + List lines = receiptItems.listReceipt(receipt.getId()).stream() + .map(line -> new ReturnOrderResponses.ReceiptItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity()), value(line.getBoxCount()))) + .toList(); + long boxes = value(receipt.getTotalBoxCount()); + if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum(); + String cartonMark = text(receipt.getCartonMark()); + List labelLines = lines.stream() + .map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount())) + .toList(); + List labels = inventory.receiptLabels(order, receipt, labelLines); + String printableHtml = combineLabels(labels); + return new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(), + receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt(), receipt.getUnitType(), receipt.getUnitCode(), boxes, lines, cartonMark, printableHtml, labels, + inventory.receiptRepairRequired(receipt, boxes)); + }).toList(); + Map> palletItems = new LinkedHashMap<>(); + Map palletTypes = new LinkedHashMap<>(); + Map physicalBoxes = new LinkedHashMap<>(); for (ReturnReceipt receipt : orderReceipts) { - if (text(receipt.getPalletCode()) == null) continue; - Map bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>()); + if (!"PALLET".equals(receipt.getUnitType()) && !"BOX".equals(receipt.getUnitType())) continue; + String physicalCode = text(receipt.getUnitCode()) == null ? text(receipt.getPalletCode()) : text(receipt.getUnitCode()); + if (physicalCode == null) continue; + String key = receipt.getUnitType() + ":" + physicalCode; + palletTypes.put(key, receipt.getUnitType()); + physicalBoxes.merge(key, value(receipt.getTotalBoxCount()), Long::sum); + Map bySku = palletItems.computeIfAbsent(key, ignored -> new LinkedHashMap<>()); for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(), - new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())), - (left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity())); + new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity()), value(line.getBoxCount())), + (left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity(), left.boxCount() + right.boxCount())); } List palletViews = palletItems.entrySet().stream().map(entry -> { List palletLines = List.copyOf(entry.getValue().values()); - return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines); + String[] parts = entry.getKey().split(":", 2); + return new ReturnOrderResponses.PalletDetail(parts.length > 1 ? parts[1] : entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines, + physicalBoxes.getOrDefault(entry.getKey(), 0L), palletTypes.get(entry.getKey()), parts.length > 1 ? parts[1] : entry.getKey()); }).toList(); List photos = photoList(order).stream() .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); - return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos); + return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos, + inventory.packingHistory(order.getCustomerId(), order.getReturnNo())); + } + + private String combineLabels(List labels) { + if (labels.isEmpty()) return null; + // Keep the label's physical page size and barcode layout in the combined print file. + java.util.Set styles = new java.util.LinkedHashSet<>(); + var stylePattern = java.util.regex.Pattern.compile("]*>(.*?)", java.util.regex.Pattern.DOTALL | java.util.regex.Pattern.CASE_INSENSITIVE); + for (var label : labels) { + if (label.printableHtml() == null) continue; + var matcher = stylePattern.matcher(label.printableHtml()); + while (matcher.find()) styles.add(matcher.group(1)); + } + StringBuilder html = new StringBuilder(""); + for (ReturnOrderResponses.CartonLabel label : labels) { + String value = label.printableHtml(); + if (value == null || value.isBlank()) continue; + int bodyStart = value.indexOf("', bodyStart) + 1; + int bodyEnd = value.lastIndexOf(""); + String content = contentStart > 0 && bodyEnd > contentStart ? value.substring(contentStart, bodyEnd) : value; + html.append("
").append(content).append("
"); + } + return html.append("").toString(); } public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) { @@ -255,11 +326,12 @@ public class ReturnReceiptService { if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); List result = new ArrayList<>(); for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { - ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); - if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue; - List items = cartonItems.listCarton(carton.getId()).stream() - .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); - result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); + for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { + if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue; + List items = cartonItems.listCarton(carton.getId()).stream() + .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); + result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); + } } return result; } @@ -345,6 +417,8 @@ public class ReturnReceiptService { ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId())); if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱已被其他换标申请占用,请刷新后重试"); + if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) + throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划"); long allocated = Math.min(remaining, skuQuantity); RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(source.getSkuCode()); @@ -374,10 +448,10 @@ public class ReturnReceiptService { List completedPallets = receipts.listOrder(order.getId()).stream() .filter(receipt -> text(receipt.getPalletCode()) != null) .collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> { - ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); - ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null); - return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(), - item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo()); + List lines = receiptItems.listReceipt(receipt.getId()); + String sku = lines.size() == 1 ? lines.get(0).getSkuCode() : "MIXED"; + return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), sku, + lines.stream().mapToLong(line -> value(line.getReceivedQuantity())).sum(), receipt.getReceiptBatchNo()); }, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(), left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new)) .values().stream().toList(); @@ -427,17 +501,18 @@ public class ReturnReceiptService { public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { String key = text(request.idempotencyKey()); String requestHash = hash(request); - ReturnReceipt replay = receipts.findKey(identity.operatorKey(), key); - if (replay != null) return replayReceipt(replay, requestHash); String startedBy = norm(request.startedBy()); if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU"); String trackingNo = text(request.trackingNo()); + String unitType = norm(request.unitType()); + if (unitType == null) unitType = "PALLET"; + String unitCode = text(request.unitCode()); ReturnOrder order = orders.lockNo(text(request.returnNo())); if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { throw notFound("当前绑定仓没有该退货单"); } - // Waiting for the order lock may allow the same request to commit. Use a - // current read, not the repeatable-read snapshot established by findKey. + // Lock the order before any consistent read: a snapshot opened before + // waiting would hide the winner's cartons when rendering replay labels. ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key); if (concurrentReplay != null) { if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求"); @@ -448,8 +523,34 @@ public class ReturnReceiptService { } List lockedItems = orderItems.lockOrder(order.getId()); Map receivedBySku = aggregate(request.items()); + Map boxCountBySku = request.items().stream().filter(item -> item.boxCount() != null) + .collect(java.util.stream.Collectors.toMap(item -> text(item.skuCode()), PdaReturnReceiptRequests.Item::boxCount, Math::addExact, LinkedHashMap::new)); List allocations = allocate(lockedItems, receivedBySku); long receiptQuantity = allocations.stream().mapToLong(Allocation::quantity).sum(); + if (!Set.of("PALLET", "BOX", "PARCEL").contains(unitType)) throw badRequest("unitType 必须为 PALLET、BOX 或 PARCEL"); + for (PdaReturnReceiptRequests.Item input : request.items()) { + if (!"PARCEL".equals(unitType) && (input.boxCount() == null || input.boxCount() <= 0)) throw badRequest("托盘或箱子签收的每 SKU 箱数必须大于 0"); + if ("PARCEL".equals(unitType) && input.boxCount() != null && input.boxCount() != 0) throw badRequest("包裹签收不填写箱数"); + } + // 托盘总箱数以本次各 SKU 箱数之和为准,避免操作员重复填写并产生不一致。 + long totalBoxCount = "BOX".equals(unitType) ? 1 : "PALLET".equals(unitType) + ? boxCountBySku.values().stream().mapToLong(Long::longValue).sum() : 0; + if ("PALLET".equals(unitType) && totalBoxCount <= 0) throw badRequest("托盘签收的总箱数必须大于 0"); + if ("PALLET".equals(unitType) && boxCountBySku.values().stream().anyMatch(count -> count > totalBoxCount)) + throw badRequest("每 SKU 箱数不能超过本托总箱数"); + if ("BOX".equals(unitType) && boxCountBySku.values().stream().anyMatch(count -> count != 1)) + throw badRequest("单箱签收时每 SKU 箱数应为 1"); + if ("PARCEL".equals(unitType) && request.totalBoxCount() != null) throw badRequest("包裹签收不填写箱数"); + if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode()); + if (unitCode == null && "BOX".equals(unitType)) + unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1"; + if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空"); + if ("PALLET".equals(unitType) + && (request.length() == null || request.width() == null || request.height() == null || request.weight() == null + || request.length().signum() <= 0 || request.width().signum() <= 0 + || request.height().signum() <= 0 || request.weight().signum() <= 0)) { + throw badRequest("托盘签收必须填写有效的长、宽、高和重量"); + } LocalDateTime now = LocalDateTime.now(); long receiptId = IdWorker.getId(); ReturnReceipt receipt = new ReturnReceipt(); @@ -457,7 +558,10 @@ public class ReturnReceiptService { receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId)); receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId)); receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED"); - receipt.setPalletCode(text(request.palletCode())); + receipt.setUnitType(unitType); receipt.setUnitCode(unitCode); + receipt.setTotalBoxCount(totalBoxCount); + receipt.setPalletCode("PALLET".equals(unitType) ? unitCode : ("BOX".equals(unitType) ? text(request.palletCode()) : null)); + receipt.setLength(request.length()); receipt.setWidth(request.width()); receipt.setHeight(request.height()); receipt.setWeight(request.weight()); receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey()); receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash); receipt.setSignedAt(now); receipt.setCreatedAt(now); @@ -474,7 +578,7 @@ public class ReturnReceiptService { ReturnReceiptItem receiptItem = new ReturnReceiptItem(); receiptItem.setId(IdWorker.getId()); receiptItem.setReceiptId(receiptId); receiptItem.setReturnOrderItemId(item.getId()); receiptItem.setCustomerId(order.getCustomerId()); receiptItem.setSkuCode(item.getSkuCode()); - receiptItem.setReceivedQuantity(allocation.quantity()); receiptItems.insert(receiptItem); + receiptItem.setReceivedQuantity(allocation.quantity()); receiptItem.setBoxCount("PARCEL".equals(unitType) ? null : boxCountBySku.get(item.getSkuCode())); receiptItems.insert(receiptItem); } Map inventoryLines = new LinkedHashMap<>(); for (Allocation allocation : allocations) { @@ -484,18 +588,22 @@ public class ReturnReceiptService { left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(), Math.addExact(left.quantity(), right.quantity()))); } - if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) { - throw conflict("一个签收批次只能签收一个 SKU"); + List inventoryCartons; + if ("PALLET".equals(unitType)) { + inventoryCartons = inventory.createReceiptCartons(order, receipt, new ArrayList<>(inventoryLines.values()), boxCountBySku); + } else if ("PARCEL".equals(unitType)) { + inventoryCartons = new ArrayList<>(); + for (var line : inventoryLines.values()) + inventoryCartons.add(inventory.createReceiptCarton(order, receipt, null, List.of(line))); + } else { + inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values()))); } - receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()), - new ArrayList<>(inventoryLines.values())).getCartonMark()); + receipt.setCartonMark(inventoryCartons.get(0).getCartonMark()); receipts.updateById(receipt); + syncTailCargo(order, receipt, allocations, now); List finalItems = orderItems.listOrder(order.getId()); long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum(); boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity())); - if (completed && photoFiles != null && photoList(order).isEmpty()) { - throw conflict("本次将完成签收,请先拍摄并上传退货打托照片"); - } order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order); syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0); ReturnReceiptBatch batch = new ReturnReceiptBatch(); @@ -517,6 +625,50 @@ public class ReturnReceiptService { lastlegOrders.updateById(tail); } + /** Keep the platform tail-leg order detail in sync with PDA receipt facts. */ + private void syncTailCargo(ReturnOrder order, ReturnReceipt receipt, List allocations, LocalDateTime now) { + if (lastlegOrderDetails == null || order.getLastlegOrderId() == null) return; + LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId()); + if (tail == null || !Objects.equals(order.getCustomerId(), tail.getCustomerId())) return; + Map quantities = new LinkedHashMap<>(); + Map boxes = new LinkedHashMap<>(); + Map names = new LinkedHashMap<>(); + for (Allocation allocation : allocations) { + String sku = allocation.item().getSkuCode(); + quantities.merge(sku, allocation.quantity(), Math::addExact); + names.putIfAbsent(sku, allocation.item().getProductNameZh()); + } + for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) { + boxes.merge(line.getSkuCode(), value(line.getBoxCount()), Math::addExact); + } + int packageCount = 0; + boolean firstSku = true; + for (Map.Entry entry : quantities.entrySet()) { + int skuBoxes = Math.toIntExact("PARCEL".equals(receipt.getUnitType()) ? 1 : boxes.getOrDefault(entry.getKey(), 0L)); + if (skuBoxes <= 0) skuBoxes = 1; + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setId(IdWorker.getId()); detail.setCustomerId(order.getCustomerId()); detail.setOrderId(tail.getId()); + detail.setSourceInboundDetailId("RETURN_RECEIPT:" + receipt.getId() + ":" + entry.getKey()); + detail.setSkuCode(entry.getKey()); detail.setProductNameZh(names.get(entry.getKey())); detail.setPackageCount(skuBoxes); + detail.setProductQuantity(Math.toIntExact(entry.getValue())); detail.setPalletCount("PALLET".equals(receipt.getUnitType()) ? 1 : 0); + detail.setGrossWeightKg(firstSku ? receipt.getWeight() : null); detail.setVolumeCbm(firstSku && receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null + ? receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)) : null); + detail.setShippingMark(receipt.getCartonMark()); + lastlegOrderDetails.insert(detail); + packageCount = Math.addExact(packageCount, skuBoxes); + firstSku = false; + } + tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), packageCount)); + tail.setReceivedPackageCount(Math.addExact(tail.getReceivedPackageCount() == null ? 0 : tail.getReceivedPackageCount(), packageCount)); + tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum()))); + if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1)); + if (receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null) { + tail.setVolumeCbm(receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000))); + } + tail.setActualArrivalAt(tail.getActualArrivalAt() == null ? now : tail.getActualArrivalAt()); + tail.setUpdatedAt(now); lastlegOrders.updateById(tail); + } + private List photoList(ReturnOrder order) { return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId()); } @@ -558,8 +710,22 @@ public class ReturnReceiptService { return confirmed(order, receipt, COMPLETED.equals(order.getStatus())); } private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) { + String cartonMark = text(receipt.getCartonMark()); + String printableHtml = null; + List labels = new java.util.ArrayList<>(); + if (receipt.getId() != null) { + for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { + var label = inventory.label(order.getCustomerId(), carton.getCartonMark()); + if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml())); + } + } + if (cartonMark != null) { + var label = inventory.label(order.getCustomerId(), cartonMark); + if (label != null) printableHtml = label.printableHtml(); + } return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), - order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); + order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt(), receipt.getUnitType(), receipt.getUnitCode(), + cartonMark, printableHtml, value(receipt.getTotalBoxCount()), labels); } private Map> relabelSubOrders(List values) { var ids = values.stream().map(ReturnOrder::getLastlegOrderId).filter(java.util.Objects::nonNull).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 7572a8948..c5596b255 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -23,6 +23,7 @@ public final class OverseasPermissions { public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; + public static final String INVENTORY_MANAGE = "lastleg:inventory:manage"; public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return"; public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status"; @@ -79,6 +80,7 @@ public final class OverseasPermissions { public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create"; public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit"; public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status"; + public static final String CARGO_DISCARD_LIST = "platform:overseas-warehouse:discard:list"; public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list"; public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create"; public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 214ec7563..771723207 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -91,7 +91,7 @@ public class LastlegOrderService { public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { Page page = orderMapper.selectPage( - new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt)); + new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); Map> mergeRelations = palletMergeRelations(page.getRecords()); Map containerNodes = containerNodes(page.getRecords()); Map dispatchStatuses = dispatchStatuses(page.getRecords()); @@ -522,47 +522,6 @@ public class LastlegOrderService { return orders.size(); } - public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { - try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { - writeExportCsv(customerId, query, output); - return output.toByteArray(); - } catch (IOException e) { - throw new IllegalStateException("Unable to export tail-leg orders", e); - } - } - - public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) - throws IOException { - output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); - final int batchSize = 1000; - long page = 1; - while (true) { - List orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize), - filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords(); - if (orders.isEmpty()) break; - Map containerNodes = containerNodes(orders); - for (LastlegOrder order : orders) { - String line = String.join(",", List.of( - csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), - csv(displayInboundCode(order)), - csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), - csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), - csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) - ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(yesOrNo(order.getAbnormal())), csv(yesOrNo(order.getDetained())), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()), - csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), - csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), - csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())), - csv(order.getPalletMergeFlag()), csv(LastlegOrderStatus.labelOf(order.getCurrentStatus())), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), - csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n"; - output.write(line.getBytes(StandardCharsets.UTF_8)); - } - output.flush(); - if (orders.size() < batchSize) break; - page++; - } - } - private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { return filter(customerId, query, null); } @@ -574,6 +533,7 @@ public class LastlegOrderService { String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null; return new LambdaQueryWrapper() + .ne(LastlegOrder::getOrderType, "CARGO_DISCARD") .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .and(StringUtils.hasText(query.orderNo()), nested -> nested .like(LastlegOrder::getOrderNo, query.orderNo()) @@ -584,6 +544,8 @@ public class LastlegOrderService { .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) + .apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL, + query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) @@ -714,11 +676,6 @@ public class LastlegOrderService { ? "COMPLETED" : "PENDING_PROCESSING"; } - private static String yesOrNo(Boolean value) { - if (value == null) return null; - return value ? "是" : "否"; - } - private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } catch (NumberFormatException ignored) { return null; } @@ -730,10 +687,4 @@ public class LastlegOrderService { return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix; } - private static String csv(Object value) { - if (value == null) return ""; - String text = String.valueOf(value); - if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; - return '"' + text.replace("\"", "\"\"") + '"'; - } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8c187eeb6..c59bf9f06 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1937,7 +1937,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', - dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID', + return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥', packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), @@ -1982,8 +1983,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), @@ -2572,6 +2574,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间', discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', @@ -2594,6 +2597,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID', sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', + completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组', actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), @@ -2985,7 +2989,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; -SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no')); +SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_batch_no')); PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt; -- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); @@ -3101,3 +3105,81 @@ CREATE TABLE IF NOT EXISTS event_delivery_attempt ( KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。 +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +CREATE TABLE IF NOT EXISTS ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细', + mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消', + remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no), + UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key), + KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at) +) ENGINE=InnoDB COMMENT='退货合箱计划'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); +CREATE TABLE IF NOT EXISTS ll_return_packing_trace ( + id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键', + operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号', + sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数', + created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id), + KEY idx_return_packing_trace_order(customer_id,return_no,created_at), + KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code), + KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code), + CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯'; +ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +-- Existing outbound tables need this field even when created before the fee snapshot baseline. +USE baiyue_lastleg; +SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照'''); +PREPARE return_outbound_stmt FROM @return_outbound_ddl; +EXECUTE return_outbound_stmt; +DEALLOCATE PREPARE return_outbound_stmt; diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 7ec891f89..96ff377c3 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -92,6 +92,10 @@ paths: - OPEN - PAUSED - CLOSED + - name: scope + in: query + description: 默认AVAILABLE,仅开放且车次仍PLANNED、当前服务端时间早于交货截止且截止不晚于计划发车;数据库分页前筛选,不以容量已知或有余位为条件。ALL包含草稿和历史,仅管理权限可用;车次发布设置读取已有发布须显式传ALL。 + schema: { type: string, enum: [AVAILABLE, ALL], default: AVAILABLE } - name: canConfirm in: query required: false diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 9c464547d..2bdb4cd4c 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -175,108 +175,6 @@ } } }, - "/api/platform/v1/last-leg/fleet/operations/export": { - "get": { - "description": "platform:fleet:operations:export;同筛选同事实来源的CSV", - "parameters": [ - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 1, - "minimum": 1 - } - }, - { - "name": "size", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 20, - "minimum": 1, - "maximum": 200 - } - }, - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "warehouseId", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1 - } - }, - { - "name": "keyword", - "in": "query", - "required": false, - "schema": { - "type": "string", - "maxLength": 128 - } - }, - { - "name": "sourceType", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "OWN", - "EXTERNAL" - ] - } - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "IN_TRANSIT", - "COMPLETED" - ] - } - } - ], - "responses": { - "200": { - "description": "UTF-8 BOM CSV", - "content": { - "text/csv": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record": { "parameters": [ { diff --git a/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml index d98691fea..a76b5cb61 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml @@ -600,89 +600,6 @@ } } } - }, - "/api/platform/v1/last-leg/fleet/quotes/export": { - "get": { - "description": "platform:fleet:quote:export;全筛选CSV,最多5000行,公式注入转义,空报价保留空值,币种不混合", - "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": "kind", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "PURCHASE", - "SALE" - ] - } - }, - { - "name": "keyword", - "in": "query", - "required": false, - "schema": { - "type": "string", - "maxLength": 128 - } - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "ACTIVE", - "INACTIVE" - ] - } - }, - { - "name": "effectiveOn", - "in": "query", - "required": false, - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "UTF-8 BOM CSV", - "content": { - "text/csv": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } } }, "components": { diff --git a/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml new file mode 100644 index 000000000..dba8fc6c2 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml @@ -0,0 +1,51 @@ +openapi: 3.0.3 +info: + title: Lastleg download-center export provider + version: 1.0.0 + description: | + Admin 导出统一通过 POST /api/platform/v1/files/export-tasks 创建 XLSX 任务。 + File 按导出类型校验原业务 export 权限,通过内部令牌分批读取 Lastleg;Provider 不经公网 Gateway。 + parameters 与对应业务列表筛选一致,不包含 page/size;进销存支持 customerName。 + 尾程订单支持 relabelNo 和 sailingStage,库存余额按来源入库单展开,不导出内部数据库 ID。 + 每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。 + snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。 + TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。 + 原 Lastleg 业务 /export CSV 公网接口已移除。文件领取、过期、失败重试由 File 下载中心管理。 +paths: + /internal/exports/v1/{exportType}/batches: + post: + summary: 读取一批平台导出业务数据 + parameters: + - in: header + name: X-Internal-Service-Token + required: true + schema: { type: string } + - in: path + name: exportType + required: true + schema: + type: string + enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, limit] + properties: + taskId: { type: string } + tenantId: { type: string, enum: ['0'] } + appCode: { type: string, enum: [PLATFORM] } + requestedBy: { type: string } + dispatchVersion: { type: integer, minimum: 1 } + parameters: { type: object, additionalProperties: true } + cursor: { type: string, nullable: true } + snapshotToken: { type: string, nullable: true } + limit: { type: integer, minimum: 1, maximum: 200 } + responses: + '200': + description: ApiResponse 包装 ExportBatchResponse;首批返回 columns/totalRows,后续批次返回同一 schemaHash/snapshotToken 与递增游标。 + '400': { description: 类型、参数、游标或筛选快照无效 } + '401': { description: 内部令牌无效 } + '403': { description: 非平台作用域 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6cfc4e2cd..99eb066fc 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -214,6 +214,7 @@ paths: description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: + - { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: orderNo, in: query, schema: { type: string } } @@ -338,12 +339,6 @@ paths: orderIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } } palletMergeRequired: { type: boolean } responses: { '200': { description: 批量合托标记已更新 } } - /api/platform/v1/last-leg/orders/export: - get: - summary: 导出筛选后的尾程订单 - description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。 - security: [{ bearerAuth: [] }] - responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } } /api/platform/v1/last-leg/orders/container-summaries: get: summary: 分页查询柜级入库计划进度 @@ -586,6 +581,10 @@ paths: - { name: vehiclePlate, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } } - { name: fbaAppointmentNo, in: query, schema: { type: string } } + - name: stage + in: query + description: 服务端在分页前筛选;BEFORE_LOADING=待发车未拣货,LOADING=拣货中或拣货完成待发车,AFTER_LOADING=已发车(含部分),CANCELLED=已取消;与status取交集。 + schema: { type: string, enum: [BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED] } responses: '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } /api/platform/v1/last-leg/dispatches/{dispatchNo}: @@ -1168,6 +1167,8 @@ paths: summary: 客户分页查询自己的海外尾程订单 description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] + parameters: + - { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } } responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } /api/merchant/v1/last-leg/orders/{id}: get: @@ -1766,7 +1767,7 @@ paths: /api/pda/v1/last-leg/return-receipts/confirm: post: summary: PDA 确认退货签收 - description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;trackingNo 为本次包裹可选快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。 + description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;按 PALLET、BOX、PARCEL 选择现场实物维度。托盘必须提交托盘码、长宽高和重量并计算体积;箱子可散装,也可额外绑定托盘;包裹只需提交包裹/快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnReceiptConfirm' } } } } responses: { '200': { description: 签收结果及自动完成状态 }, '409': { description: 超收、错绑或幂等键冲突 } } @@ -2017,7 +2018,13 @@ components: returnNo: { type: string, maxLength: 128 } trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' } startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' } - cartonMark: { type: string, nullable: true, maxLength: 128, description: PDA 实际绑定的箱号;不传时由系统生成 } + unitType: { type: string, enum: [PALLET, BOX, PARCEL], default: PALLET, description: 本次签收实物维度 } + unitCode: { type: string, nullable: true, maxLength: 128, description: 托盘码或包裹/快递单号;箱子不传时由系统生成内部箱号 } + palletCode: { type: string, nullable: true, maxLength: 128, description: 箱子可选的托盘绑定码;托盘类型下与 unitCode 相同 } + length: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘长度,CM;托盘类型必填 } + width: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘宽度,CM;托盘类型必填 } + height: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘高度,CM;托盘类型必填 } + weight: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘重量,KG;托盘类型必填 } idempotencyKey: { type: string, maxLength: 255 } items: type: array @@ -2029,6 +2036,7 @@ components: properties: skuCode: { type: string, maxLength: 128 } receivedQuantity: { type: integer, minimum: 1 } + boxCount: { type: integer, nullable: true, minimum: 1, description: 托盘或箱子签收时该 SKU 对应箱数,包裹签收不填 } ReturnInventoryMerge: type: object required: [returnNos, items, length, width, height, weight, idempotencyKey] @@ -2947,10 +2955,28 @@ components: partialCompletionReason: { type: string, nullable: true } createdAt: { type: string, format: date-time } items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } + workflow: + nullable: true + description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 + allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }] bolLines: type: array description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 items: { $ref: '#/components/schemas/DispatchBolLine' } + DispatchWorkflow: + type: object + required: [tripNo, dispatchReview, loadingReview] + properties: + tripNo: { type: string } + dispatchReview: { $ref: '#/components/schemas/DispatchReview' } + loadingReview: { $ref: '#/components/schemas/DispatchReview' } + DispatchReview: + type: object + required: [status] + properties: + status: { type: string, enum: [PENDING, APPROVED, REJECTED] } + reason: { type: string, nullable: true } + reviewedAt: { type: string, format: date-time, nullable: true } DispatchItem: type: object properties: diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml index fc59b8d73..7cdea3859 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -44,17 +44,6 @@ paths: data: type: array items: { $ref: '#/components/schemas/PartnerWarehouseOption' } - /api/platform/v1/last-leg/master-data/overseas-quotes/export: - get: - summary: 导出海外仓报价 - description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV' - parameters: - - { name: keyword, in: query, schema: { type: string } } - - { name: warehouseId, in: query, schema: { type: string } } - - { name: providerName, in: query, schema: { type: string } } - - { name: currency, in: query, schema: { type: string } } - - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } - responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } /api/platform/v1/last-leg/master-data/overseas-quotes/{id}: parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } @@ -96,15 +85,6 @@ paths: summary: 查询最新有效月度汇率摘要 description: '权限:platform:overseas-master-data:exchange-rate:list' responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } } - /api/platform/v1/last-leg/master-data/exchange-rates/export: - get: - summary: 导出筛选后的全部月度汇率 - description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV' - parameters: - - { name: monthFrom, in: query, schema: { type: string } } - - { name: monthTo, in: query, schema: { type: string } } - - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } - responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } /api/platform/v1/last-leg/master-data/exchange-rates/{month}: parameters: - { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } @@ -142,16 +122,6 @@ paths: description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一' requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } } responses: { '200': { description: 业务响应,data为Vehicle } } - /api/platform/v1/last-leg/master-data/vehicles/export: - get: - summary: 导出筛选后的全部车辆主数据 - description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV' - parameters: - - { name: keyword, in: query, schema: { type: string } } - - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } - - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } - - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } - responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } /api/platform/v1/last-leg/master-data/vehicles/{id}: parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index c0247f9a3..6610f9397 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -74,19 +74,20 @@ paths: '404': { description: 当前绑定仓无此任务 } /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick: post: - summary: 扫码拣取一件代发箱件 + summary: 按当前出库任务扫码拣货 + description: 退货散箱按指定箱唛或SKU提交;选择整托出库的退货任务必须扫描托盘码,不允许逐箱提交。 parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } } responses: { '200': { description: 更新后的统一出库任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '409': { description: 库存、数量或幂等冲突 } } /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick: post: - summary: 扫描一件代发托盘并原子拣取整托剩余箱件 - description: 仅适用于一件代发出库;按托盘汇总本任务内全部 SKU 和未拣箱唛,任一库存校验失败则整托回滚。 + summary: 扫描出库托盘并原子拣取整托剩余箱件 + description: 适用于一件代发、换标、仓储转运和退货整托出库;退货按每箱一条冻结明细核对整托全部预留箱、客户和状态,内容变化拒绝提交。任一库存校验失败则整托回滚。 parameters: - { name: taskNo, in: path, required: true, schema: { type: string } } - { name: palletNo, in: path, required: true, schema: { type: string } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } - responses: { '200': { description: 整托拣货后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } } + responses: { '200': { description: 整托拣货后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '404': { description: 当前任务没有该托盘 }, '409': { description: 出库类型不支持、托盘内容变化、库存不足或幂等冲突 } } /api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete: post: summary: 完成一件代发出库任务 @@ -858,9 +859,11 @@ components: remark: { type: string, maxLength: 1000, nullable: true } DispatchPick: type: object - required: [positionId, packages, productQuantity, idempotencyKey] + description: 普通库存提交positionId;原生退货换标箱提交cartonMark、positionId为空、packages=1,productQuantity必须与箱快照一致。两类来源互斥。 + required: [packages, productQuantity, idempotencyKey] properties: - positionId: { type: string, pattern: '^[0-9]+$' } + positionId: { type: string, pattern: '^[0-9]+$', nullable: true } + cartonMark: { type: string, maxLength: 255, description: 本排车来源的系统箱唛 } packages: { type: integer, format: int64, minimum: 1 } productQuantity: { type: integer, format: int64, minimum: 0 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } diff --git a/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml new file mode 100644 index 000000000..b579ee2a8 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml @@ -0,0 +1,157 @@ +openapi: 3.0.3 +info: + title: 退货合箱计划与箱级上架 + version: 1.0.0 + description: 散SKU查询客户全部同仓PARCEL可用库存;箱合并精确指定来源箱唛并扫描已有目标箱。托盘仅为可选载体。合箱不新增订单货量。 +security: [{ bearerAuth: [] }] +paths: + /api/platform/v1/last-leg/return-packing-plans: + get: + summary: 合箱计划分页 + description: return-inventory:list;page/size/status/planNo 查询,planNo 去除首尾空格后按字面包含匹配,统计与分页使用相同条件。每条计划返回 createdAt 和 completedAt(实际合箱完成时间);已完成计划 resultCartons 返回实际箱唛、SKU数量及当前库位、载体托盘。完成时间不使用箱子的 putawayAt,原箱多次合箱时仍显示本次计划完成时间。 + parameters: + - { name: planNo, in: query, schema: { type: string, maxLength: 128 }, description: 合箱计划号,支持部分编号查询 } + - { name: customerId, in: query, schema: { type: string, maxLength: 64 }, description: 客户筛选 } + - { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 64 }, description: 海外仓筛选 } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 }, description: 计划箱内 SKU,支持部分编码;多条件同时匹配 } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + post: + summary: 创建并锁定来源库存 + description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU只锁散件指定数量。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Create' } } } } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/platform/v1/last-leg/return-packing-plans/{planNo}/review: + post: + summary: 审核并推送PDA + description: return-inventory:list + lastleg:inventory:manage;PENDING_REVIEW→READY。 + parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/platform/v1/last-leg/return-packing-plans/{planNo}/cancel: + post: + summary: 取消并释放本计划预占 + description: return-inventory:list + lastleg:inventory:manage;仅待审或READY,完成不可取消。 + parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/packing/plans: + get: + summary: 绑定仓已审核合箱计划 + description: lastleg:pda:operate + ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR;只返回READY任务。 + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}: + get: + summary: 读取精确锁定的来源和数量 + description: 绑定仓范围;sources含来源箱逐SKU锁定件数,boxes含汇总数量。 + parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete: + post: + summary: 多箱分配并完成计划 + description: SKU目标总量须等于锁定量。BOX先选择新增目标箱或扫描已有目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。 + parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}: + get: + summary: 扫描可用来源或目标箱 + description: 仅同仓AVAILABLE实物箱,PARCEL不作为箱选择。 + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/merge-boxes: + post: + summary: 来源箱并入已有目标箱 + description: 同客户同仓,已有目标箱须与来源不同。sourceDeductions指定各来源箱SKU实际扣除量;未传则整箱移动。targetCartonMark为空时新增系统箱,必须填写箱规。保留逐退货单来源追溯。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/BoxMerge' } } } } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/pack: + post: + summary: 散SKU分配多个新箱 + description: 同客户全部散件来源,所有新箱原子提交;同键异内容拒绝。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Pack' } } } } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/putaway: + post: + summary: 退货箱扫码库位上架 + description: 绑定仓有效非虚拟库位、AVAILABLE箱且无预占;palletCode选填。返回库位、托盘、上架时间,不计为普通头程新收货。 + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Putaway' } } } } + responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + CartonMergeHistory: + type: object + description: 箱号详情 mergeHistory 按时间倒序显示实际合入及合出数量,原有目标库存不计入本次合箱数量;历史未关联计划号时 planNo 为空。 + properties: + planNo: { type: string, nullable: true } + sourceCartonMark: { type: string } + targetCartonMark: { type: string } + returnNo: { type: string } + skuCode: { type: string } + quantity: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + Item: + type: object + required: [skuCode, quantity] + properties: + skuCode: { type: string, maxLength: 128 } + quantity: { type: integer, format: int64, minimum: 1 } + Box: + type: object + properties: + items: { type: array, items: { $ref: '#/components/schemas/Item' }, maxItems: 100 } + targetCartonMark: { type: string, maxLength: 128, description: BOX模式已有目标箱 } + length: { type: number, exclusiveMinimum: true, minimum: 0, description: cm } + width: { type: number, exclusiveMinimum: true, minimum: 0, description: cm } + height: { type: number, exclusiveMinimum: true, minimum: 0, description: cm } + weight: { type: number, exclusiveMinimum: true, minimum: 0, description: kg } + Create: + type: object + required: [returnNos, mode, boxes, idempotencyKey] + properties: + returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + mode: { type: string, enum: [SKU, BOX] } + sourceCartonMarks: { type: array, maxItems: 500, items: { type: string } } + boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } } + remark: { type: string, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + Complete: + type: object + required: [boxes, idempotencyKey] + properties: + boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } } + idempotencyKey: { type: string, maxLength: 255 } + sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' } } + SourceDeduction: + type: object + required: [cartonMark, skuCode, quantity] + properties: + cartonMark: { type: string } + skuCode: { type: string } + quantity: { type: integer, format: int64, minimum: 1 } + Pack: + allOf: + - { $ref: '#/components/schemas/Complete' } + - type: object + required: [returnNos] + properties: + returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + BoxMerge: + type: object + required: [sourceCartonMarks, idempotencyKey] + properties: + sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + targetCartonMark: { type: string } + idempotencyKey: { type: string, maxLength: 255 } + sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' } } + length: { type: number, minimum: 0, exclusiveMinimum: true } + width: { type: number, minimum: 0, exclusiveMinimum: true } + height: { type: number, minimum: 0, exclusiveMinimum: true } + weight: { type: number, minimum: 0, exclusiveMinimum: true } + Putaway: + type: object + required: [locationCode, idempotencyKey] + properties: + locationCode: { type: string, maxLength: 128 } + palletCode: { type: string, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 255 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index 8e378e736..b05e0e1b0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -38,11 +38,11 @@ class LastlegOrderControllerTest { private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void orderQueryHasOneResolvableConstructorForRequestParameterBinding() { - assertEquals(16, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); + assertEquals(17, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); } @Test @@ -123,31 +123,9 @@ class LastlegOrderControllerTest { verify(service).containerProgressDetail("BOOKING-1"); } - @Test - void exportBuildsCsvBeforeReturningTheResponse() { - byte[] csv = "csv".getBytes(java.nio.charset.StandardCharsets.UTF_8); - when(service.exportCsv("200", query)).thenReturn(csv); - var response = controller.export(user(0L, "PLATFORM"), "200", query); - assertArrayEquals(csv, response.getBody()); - verify(service).exportCsv("200", query); - } - @Test - void platformOperatorCanExportAllCustomersWithoutFilters() { - byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8); - LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query( - null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); - when(service.exportCsv(null, emptyQuery)).thenReturn(csv); - - var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery); - - assertEquals(Long.valueOf(1), emptyQuery.page()); - assertEquals(Long.valueOf(20), emptyQuery.size()); - assertArrayEquals(csv, response.getBody()); - verify(service).exportCsv(isNull(), eq(emptyQuery)); - } @Test void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java index 87b632521..d04fa2097 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final MerchantLastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantListUsesOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 5f35f3b55..fd60babac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -40,6 +40,11 @@ import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -58,9 +63,11 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -113,6 +120,10 @@ class DeliveryTransferServiceTest { @Mock DropshipCartonSkuMapper dropshipCartonSkus; @Mock DropshipSkuInventoryMapper dropshipSkuInventory; @Mock WarehouseTransferPalletMapper warehouseTransferPallets; + @Mock ReturnInventoryCartonMapper returnCartons; + @Mock ReturnInventoryCartonItemMapper returnCartonItems; + @Mock ReturnInventoryBalanceMapper returnBalances; + @Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; private DeliveryTransferService service; @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; @@ -152,6 +163,221 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "dropshipCartonSkus", dropshipCartonSkus); ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems); + ReflectionTestUtils.setField(service, "returnStock", returnStock); + org.mockito.Mockito.lenient().when(returnStock.matchesReturnSource(any(), any())).thenCallRealMethod(); + } + + @Test + void returnBoxOutboundCanSelectOneCartonFromAPallet() { + ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); + ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1"); + when(returnCartons.lockMark("RC-1")).thenReturn(first); + ReturnInventoryCartonItem content = new ReturnInventoryCartonItem(); + content.setSkuCode("SKU-A"); content.setQuantity(1L); + when(returnCartonItems.lockCarton(first.getId())).thenReturn(List.of(content)); + + service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-1"), null, "return-1")); + + ArgumentCaptor item = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundPallets).insert(item.capture()); + assertNull(item.getValue().getPalletNo()); + verify(outboundOrders).insert(any(OutboundOrder.class)); + } + + @Test + void receivedPalletIsEligibleForWholePalletOutboundButParcelIsNot() { + ReturnInventoryCarton pallet = returnCarton("RC-PAL", "PAL-1"); + pallet.setSourceUnitType("PALLET"); + ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null); + parcel.setSourceUnitType("PARCEL"); + when(returnCartons.findAvailable("TENANT-A", "WH-1")).thenReturn(List.of(pallet, parcel)); + + var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1"); + + assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList()); + } + + @Test + void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() { + ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null); + parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L); + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); + line.setSkuCode("SKU-A"); line.setQuantity(3L); + when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line)); + + service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", + List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A")); + + ArgumentCaptor item = ArgumentCaptor.forClass(OutboundPalletItem.class); + ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(outboundPallets).insert(item.capture()); + verify(deliveryOrders).insert(delivery.capture()); + assertEquals("SKU-A", item.getValue().getSkuCode()); + assertEquals("RC-PARCEL", item.getValue().getCartonMarks()); + assertEquals("OUTBOUND_PENDING", parcel.getStatus()); + assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); + assertEquals(1, delivery.getValue().getPackageCount()); + } + + @Test + void singleSkuReturnOutboundRejectsMixedCarton() { + ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null); + mixed.setId(901L); mixed.setTotalQuantity(5L); + ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L); + ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L); + when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB)); + + assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", + List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + } + + @Test + void singleSkuReturnPickAcceptsSkuScanAndRejectsOtherSku() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "sku-return-1"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A"); + item.setOutboundOrderId(301L); item.setSkuCode("SKU-A"); item.setCartonMarks("RC-1"); + item.setPackageCount(1); item.setPickedPackageCount(0); + ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L); + carton.setStatus("OUTBOUND_PENDING"); carton.setTotalQuantity(3L); + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.selectById(801L)).thenReturn(item); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + + assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(801L, null, 1L, "wrong-sku", List.of(), "SKU-B"))).getStatusCode().value()); + service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(801L, null, 1L, "correct-sku", List.of(), "SKU-A")); + verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), + any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong()); + assertEquals("PICKED", item.getStatus()); + } + + @Test + void returnDeliveryDetailShowsFrozenCartonSkuAndQuantity() { + DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(501L); delivery.setCustomerId("TENANT-A"); + delivery.setDeliveryNo("DL-501"); delivery.setOutboundOrderId(301L); + delivery.setOutboundNo("OUT-301"); delivery.setOverseasWarehouseId("WH-1"); + delivery.setDeliveryGroupKey("RETURN:RT-1"); delivery.setStatus("WAIT_DELIVERY"); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND"); + OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC-1"); + ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L); + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); + line.setSkuCode("SKU-A"); line.setProductNameZh("测试品"); line.setQuantity(3L); + when(deliveryOrders.findByNo("TENANT-A", "DL-501")).thenReturn(delivery); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(returnCartons.findCustomerMark("TENANT-A", "RC-1")).thenReturn(carton); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(line)); + + var detail = service.deliveryDetail("TENANT-A", "DL-501"); + + assertEquals(1, detail.skus().size()); + assertEquals("SKU-A", detail.skus().get(0).skuCode()); + assertEquals(3L, detail.skus().get(0).productQuantity()); + assertEquals(1, detail.skus().get(0).packageCount()); + } + + @Test + void returnCartonPickDelegatesInventoryDeductionOnce() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1"); + outbound.setCustomerId("TENANT-A"); outbound.setOverseasWarehouseId("WH-1"); + outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A"); + item.setOutboundOrderId(301L); item.setPackageCount(1); item.setPickedPackageCount(0); + item.setCartonMarks("RC-1"); item.setStatus("WAIT_PICK"); + ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setStatus("OUTBOUND_PENDING"); + carton.setId(901L); + ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L); + ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.selectById(801L)).thenReturn(item); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + + service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1"))); + + verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), + any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong()); + assertEquals("PICKED", item.getStatus()); + } + + @Test + void returnPalletRejectsIndividualCartonScan() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-2"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A"); + item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1"); + item.setPackageCount(1); item.setPickedPackageCount(0); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.selectById(801L)).thenReturn(item); + + assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1")))).getStatusCode().value()); + verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong()); + } + + @Test + void returnPalletScanShipsEveryCartonAndRejectsChangedPalletContents() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-3"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A"); + item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1,RC-2"); + item.setPackageCount(1); item.setPickedPackageCount(0); item.setCartonMarks("RC-1"); + OutboundPalletItem secondItem = new OutboundPalletItem(); secondItem.setId(802L); secondItem.setCustomerId("TENANT-A"); + secondItem.setOutboundOrderId(301L); secondItem.setPalletNo("PAL-1"); secondItem.setCartonMarks("RC-2"); + secondItem.setPackageCount(1); secondItem.setPickedPackageCount(0); + ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); first.setId(901L); first.setStatus("OUTBOUND_PENDING"); + ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1"); second.setId(902L); second.setStatus("OUTBOUND_PENDING"); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item, secondItem)); + when(outboundPallets.selectById(801L)).thenReturn(item); + when(outboundPallets.selectById(802L)).thenReturn(secondItem); + when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first)); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "U-1", "PDA-701", + "PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-1"))).getStatusCode().value()); + verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong()); + + when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second)); + service.pdaPickPallet("WH-1", "U-1", "PDA-701", "PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-2")); + assertEquals(1, item.getPickedPackageCount()); assertEquals(1, secondItem.getPickedPackageCount()); + verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong()); + verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-2"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong()); + } + + private static ReturnInventoryCarton returnCarton(String mark, String palletCode) { + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(Integer.toUnsignedLong(mark.hashCode())); + carton.setCustomerId("TENANT-A"); carton.setOverseasWarehouseId("WH-1"); + carton.setSourceReturnNo("RT-1"); carton.setSourceUnitType("BOX"); + carton.setCartonMark(mark); carton.setPalletCode(palletCode); carton.setStatus("AVAILABLE"); + return carton; } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java new file mode 100644 index 000000000..25b935c9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -0,0 +1,147 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +class ReturnOutboundClosureTest { + final DeliveryTransferService service = new DeliveryTransferService(); + final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class); + final OutboundPalletItemMapper items = mock(OutboundPalletItemMapper.class); + final OutboundPdaTaskMapper tasks = mock(OutboundPdaTaskMapper.class); + final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class); + final ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class); + final ReturnInventoryCartonItemMapper contents = mock(ReturnInventoryCartonItemMapper.class); + final ReturnInventoryService stock = mock(ReturnInventoryService.class); + final OutboundPdaPickMapper picks = mock(OutboundPdaPickMapper.class); + final com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper relabelApplications = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); + final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class); + ReturnInventoryCarton carton; + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "outboundOrders", outbounds); + ReflectionTestUtils.setField(service, "outboundPallets", items); + ReflectionTestUtils.setField(service, "pdaTasks", tasks); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveries); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "returnCartonItems", contents); + ReflectionTestUtils.setField(service, "returnStock", stock); + ReflectionTestUtils.setField(service, "pdaPicks", picks); + ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); + ReflectionTestUtils.setField(service, "relabelService", relabelService); + ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1"); + carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2"); + carton.setStatus("AVAILABLE"); carton.setSourceUnitType("BOX"); carton.setTotalQuantity(6L); carton.setSkuTypes(1); + when(cartons.lockMark(carton.getCartonMark())).thenReturn(carton); + when(stock.matchesReturnSource(carton, "RT1")).thenReturn(true); + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU1"); line.setQuantity(6L); + when(contents.lockCarton(10L)).thenReturn(List.of(line)); + } + + DeliveryRequests.ReturnOutbound command(String sku) { + return new DeliveryRequests.ReturnOutbound("address", "WH1", "RT1", List.of("RC#1-1"), null, "KEY1", sku); + } + + @Test void reservesWholeMergedBoxAndCreatesDelivery() { + service.createReturnOutbound("C1", "USER", command(null)); + assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING"); + verify(items).insert(argThat((OutboundPalletItem i) -> i.getPackageCount() == 1 && "RT1,RT2".equals(i.getInboundCode()))); + verify(deliveries).insert(argThat((DeliveryOrder d) -> "WAIT_DELIVERY".equals(d.getStatus()) && d.getPackageCount() == 1)); + } + + @Test void refusesSecondCommandForReservedBox() { + service.createReturnOutbound("C1", "USER", command(null)); + assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command(null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("占用"); + verify(outbounds, times(1)).insert(any(OutboundOrder.class)); + } + + @Test void preventsSingleSkuBypassingExistingBox() { + assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("按箱唛"); + verify(outbounds, never()).insert(any(OutboundOrder.class)); + } + + @Test void singleSkuParcelReservesAndKeepsScanSku() { + carton.setSourceUnitType("PARCEL"); + service.createReturnOutbound("C1", "USER", command("SKU1")); + verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode()))); + assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING"); + } + + @Test void refusesPackingReservedQuantity() { + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU1"); + line.setQuantity(6L); line.setPackingReservedQuantity(1L); + when(contents.lockCarton(10L)).thenReturn(List.of(line)); + assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command(null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("合箱计划"); + verify(outbounds, never()).insert(any(OutboundOrder.class)); + } + + @Test void exactScanIsRequiredOnReplayAsWell() { + OutboundPdaTask task = new OutboundPdaTask(); task.setId(20L); task.setCustomerId("C1"); task.setOutboundOrderId(30L); + when(tasks.lockByNoAndWarehouse("TASK1", "WH1")).thenReturn(task); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setTaskId(20L); pick.setOutboundPalletItemId(40L); pick.setPackages(1L); + when(picks.findByKey("C1", "PICK1")).thenReturn(pick); + OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); outbound.setOutboundType("RETURN_OUTBOUND"); + when(outbounds.selectById(30L)).thenReturn(outbound); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(40L); item.setCustomerId("C1"); item.setOutboundOrderId(30L); + item.setPackageCount(1); item.setCartonMarks("RC#1-1"); when(items.selectById(40L)).thenReturn(item); + assertThatThrownBy(() -> service.pdaPick("WH1", "USER", "TASK1", + new DeliveryRequests.PdaPick(40L, null, 1L, "PICK1", List.of("WRONG")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("指定的箱唛"); + verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()); + } + + @Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() { + OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); + outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); + when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); + var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); + application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1"); + application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND"); + when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application); + OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK"); + item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); + carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L); + service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")); + assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING"); + assertThat(outbound.getStatus()).isEqualTo("CANCELLED"); + verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"), + argThat(request -> "CANCELLED".equals(request.status()))); + var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService); + sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1"); + sequence.verify(outbounds).lockGlobalByNo("OUT1"); + sequence.verify(cartons).lockMark("RC#1-1"); + sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any()); + } + + @Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() { + OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); + outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); + when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); + OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1); + when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); + assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started"); + verifyNoInteractions(relabelService); + verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 1560660bf..f7427fde9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -45,7 +45,7 @@ class ReturnOutboundPdaPersistenceTest { jdbc = new JdbcTemplate(ds); // Persist production entities through production MyBatis mappers, with real transactions. for (Class entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class, - OutboundPdaPick.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class, + OutboundPdaPick.class, DeliveryOrder.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class, ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) { String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers())) .map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " " @@ -56,13 +56,16 @@ class ReturnOutboundPdaPersistenceTest { } var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); for (Class mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, - OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class, + OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class, ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnInventoryMovementMapper.class)) configuration.addMapper(mapper); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); var session = new SqlSessionTemplate(factory.getObject()); movementMapper = session.getMapper(ReturnInventoryMovementMapper.class); balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class); var target = new DeliveryTransferService(); + ReflectionTestUtils.setField(target, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class)); + ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class)); ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class)); ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class)); @@ -77,7 +80,7 @@ class ReturnOutboundPdaPersistenceTest { stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy()); var numbers = mock(LastlegOrderNoGenerator.class); - when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); + when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); ReflectionTestUtils.setField(target, "orderNoGenerator", numbers); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); @@ -119,11 +122,34 @@ class ReturnOutboundPdaPersistenceTest { assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum()); assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum()); } + @Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() { + jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)"); + var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound( + "recipient", "WH-1", "RT-1", List.of("BOX-A", "BOX-B"), null, "pallet-create", null, "PALLET")); + var items = service.pdaTaskDetail("WH-1", task.taskNo()).items(); + assertEquals(2, items.size()); + assertTrue(items.stream().allMatch(item -> item.packageCount() == 1 && "PAL-1".equals(item.palletNo()))); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(items.get(0).outboundPalletItemId(), null, 1L, "partial", List.of("BOX-A")))); + jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id=3"); + var before = stockSnapshot(); + assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "operator", task.taskNo(), + "PAL-1", new DeliveryRequests.PdaPalletPick("whole"))); + assertEquals(before, stockSnapshot()); + jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code=NULL WHERE id=3"); + service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole")); + var after = stockSnapshot(); + service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole")); + assertEquals(after, stockSnapshot()); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + assertEquals(10L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class)); + } + @Test void wrongScanAndWrongReplayPayloadLeaveStoredInventoryUnchanged() { var task = create("scan", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "bad", List.of("BOX-B")))); - assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); var valid = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-A")); service.pdaPick("WH-1", "operator", task.taskNo(), valid); service.pdaPick("WH-1", "operator", task.taskNo(), valid); @@ -165,7 +191,7 @@ class ReturnOutboundPdaPersistenceTest { jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-OTHER' WHERE id=1"); assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stale-source", List.of("BOX-A")))); - assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); assertEquals(0, jdbc.queryForObject("SELECT picked_package_count FROM ll_outbound_pallet_item", Integer.class)); } @@ -177,7 +203,7 @@ class ReturnOutboundPdaPersistenceTest { var stockBefore = stockSnapshot(); jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')"); assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request)); - assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); var unchanged = service.pdaTaskDetail("WH-1", task.taskNo()); assertEquals("PENDING", unchanged.task().status()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index d89cdf7c7..33c6c5eec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -108,6 +108,7 @@ class CargoDiscardServiceTest { assertEquals(2, detail.summary().totalPackages()); assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); assertEquals(2, storedLinks.size()); + verify(orders, never()).insert(any(LastlegOrder.class)); verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index 483f033f3..692fa2784 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -38,7 +38,7 @@ class DispatchQueryControllerTest { @Test void platformPageQueriesAcrossCustomersWithoutCustomerId() { - DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); + DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null, null); when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); var response = controller.page(platform(), query); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 3938b095f..839c7ed0f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -59,6 +59,7 @@ class DispatchPdaWorkflowServiceTest { @Mock InventoryPositionMapper positions; @Mock InventoryCommandService inventory; @Mock DispatchPickOrderAttributionService attribution; + @Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock; @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; private DispatchPdaWorkflowService service; private ObjectMapper objectMapper; @@ -80,6 +81,7 @@ class DispatchPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "pickAttribution", attribution); + ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); } @@ -329,6 +331,31 @@ class DispatchPdaWorkflowServiceTest { return value; } + @Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() { + var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); + order.setVehiclePlate("CA-100"); + var task = task(11L, "100", "WH-1", "PDA_PENDING"); + var item = item(21L, "100", 2); + item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.selectById(21L)).thenReturn(item); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L); + when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton); + var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员", + new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1")); + assertEquals(1L, result.pickedPackages()); + var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks); + sequence.verify(attribution).prepareNativeReturn("100", 101L); + sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1")); + sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null)); + sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L)); + org.mockito.Mockito.verifyNoInteractions(inventory, positions); + } + private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) { DispatchOrder value = new DispatchOrder(); value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index fbcf9a294..9b3f1ec74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -44,6 +44,14 @@ class DispatchPickOrderAttributionServiceTest { < sql.indexOf("shipped_package_count=shipped_package_count+")); } + @Test void nativePreparationRejectsHeldOrderBeforeAnyAttribution() { + var held = new LastlegOrder(); held.setDetained(true); + when(orders.lockById("t1", 101L)).thenReturn(held); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service().prepareNativeReturn("t1", 101L)); + verifyNoInteractions(attributions); + } + @Test void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() { DispatchPickOrderAttributionService service = service(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index c0d40b167..bda740e5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest { null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", - "NYEXX2609110214", null, null, 1))); + "NYEXX2609110214", null, null, 1)), null); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, @@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest { new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", "NYEXX2609110214", null, null, 1), new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", - "NYEXX2609110215", null, null, 1))); + "NYEXX2609110215", null, null, 1)), null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 1cbb76918..9e3f0e654 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -63,12 +63,16 @@ class DispatchServiceTest { @Mock LastlegOrderMapper tailOrders; @Mock OutboundOrderMapper outboundOrders; @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; + @Mock ReturnRelabelDispatchStock returnRelabelStock; private DispatchService service; @BeforeEach void setUp() { service = new DispatchService(); + ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); + ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "bolLines", bolLines); @@ -445,7 +449,7 @@ class DispatchServiceTest { resultPage.setRecords(List.of(first, second)); when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); - var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null)); + var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null)); assertEquals(2, result.total()); verify(items).findByDispatchOrder("100", 1L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStockTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStockTest.java new file mode 100644 index 000000000..b731420c0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStockTest.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReturnRelabelDispatchStockTest { + @Mock DispatchCandidateMapper candidates; + @Mock ReturnInventoryCartonMapper cartons; + @Mock PalletPhysicalGateway physical; + @Mock ReturnInventoryService inventory; + ReturnRelabelDispatchStock service; + DispatchItem item; + ReturnInventoryCarton carton; + @BeforeEach void setup() { + service = new ReturnRelabelDispatchStock(); + ReflectionTestUtils.setField(service, "candidates", candidates); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "physical", physical); + ReflectionTestUtils.setField(service, "inventory", inventory); + item = new DispatchItem(); item.setCustomerId("C"); item.setOverseasWarehouseId("9"); + item.setPalletNo("P1"); item.setCartonMarks("RC1#2-1,RC1#2-2"); item.setSourceBusinessNo("RL1"); + carton = new ReturnInventoryCarton(); carton.setId(11L); carton.setCustomerId("C"); carton.setOverseasWarehouseId("9"); + carton.setCartonMark("RC1#2-1"); carton.setPalletCode("P1"); carton.setRelabelNo("RL1"); + carton.setSourceReturnNo("RT1,RT2"); carton.setTotalQuantity(6L); carton.setStatus("OUTBOUND_PENDING"); + } + @Test void candidatesResolveRealCarrierWithoutCreatingOrdinaryStock() { + var row = new DispatchPalletCandidate().palletNo("P1").overseasWarehouseId("9").returnCartonSource(true); + when(candidates.nativeReturnRelabelAvailable("C", null, "RL1")).thenReturn(List.of(row)); + when(physical.resolve("P1")).thenReturn(new PalletPhysicalGateway.PalletReference(55L, "P1", 9L, 4L, "OCCUPIED", "ONT8", 0)); + assertEquals(55L, service.candidates("C", null, "RL1").get(0).warehousePalletId()); + verifyNoInteractions(inventory, cartons); + } + @Test void wrongScanAndMultiBoxRequestNeverDeductStock() { + assertThrows(ResponseStatusException.class, () -> service.pick(item, "OTHER", 1, 6, 100, "DIS1")); + assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 2, 6, 100, "DIS1")); + verifyNoInteractions(inventory, cartons); + } + @Test void foreignCustomerOrChangedCarrierNeverDeductsStock() { + when(cartons.lockMark("RC1#2-1")).thenReturn(carton); + carton.setCustomerId("OTHER"); + assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1")); + carton.setCustomerId("C"); carton.setPalletCode("P2"); + assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1")); + verifyNoInteractions(inventory); + } + @Test void exactBoxShipsItsFullSourceSetAndRejectsStaleQuantity() { + when(cartons.lockMark("RC1#2-1")).thenReturn(carton); + assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 5, 100, "DIS1")); + verifyNoInteractions(inventory); + assertEquals(11L, service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1").getId()); + verify(inventory).shipCarton("C", "9", "RT1,RT2", "RC1#2-1", "DIS1", 100); + } + @Test void cancelledDispatchRestoresRelabelReservationWithoutMakingItPackable() { + item.setCartonMarks("RC1#2-1"); + when(cartons.lockMark("RC1#2-1")).thenReturn(carton); + when(cartons.updateById(carton)).thenReturn(1); + service.release(item); + assertEquals("RELABEL_PENDING", carton.getStatus()); assertEquals("RL1", carton.getRelabelNo()); + verifyNoInteractions(inventory); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java new file mode 100644 index 000000000..726ee8912 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.export; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsModels; +import com.baiyue.lastleg.fleet.reporting.TripOperationsService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.validation.Validation; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LastlegExportProviderServiceTest { + private LastlegExportProviderService service; + private LastlegOrderService orders; + private InventoryQueryService inventory; + private MonthlyExchangeRateService rates; + private TripOperationsService operations; + private jakarta.validation.ValidatorFactory validators; + + @BeforeEach void setup() { + service = new LastlegExportProviderService(); + orders = mock(LastlegOrderService.class); inventory = mock(InventoryQueryService.class); + rates = mock(MonthlyExchangeRateService.class); operations = mock(TripOperationsService.class); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "rates", rates); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules().disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)); + validators = Validation.buildDefaultValidatorFactory(); + ReflectionTestUtils.setField(service, "validator", validators.getValidator()); + ReflectionTestUtils.setField(service, "signingKey", "test-internal-token-with-at-least-32-bytes"); + } + @AfterEach void close() { validators.close(); } + + @Test void batchesAllRowsAndBindsSnapshotToFiltersRequesterAndAttempt() { + var row = new MonthlyExchangeRateDtos.Response("2026-10", "USD", "CNY", new BigDecimal("7.12"), "ACTIVE", 1, "operator", LocalDateTime.of(2026,10,8,12,0), null); + when(rates.page(any())).thenAnswer(call -> { + MonthlyExchangeRateDtos.Query q = call.getArgument(0); + return new PageResult<>(List.of(row), q.page(), q.size(), 2, 2); + }); + var first = service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "ACTIVE"), null, null)); + assertTrue(first.hasMore()); assertEquals(2L, first.totalRows()); + assertEquals(new BigDecimal("7.12"), first.rows().get(0).get("usdToCnyAverage")); + assertEquals("2026-10-08T12:00:00", first.rows().get(0).get("updatedAt")); + var second = service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "ACTIVE"), first.nextCursor(), first.snapshotToken())); + assertFalse(second.hasMore()); assertTrue(second.columns().isEmpty()); assertEquals(first.schemaHash(), second.schemaHash()); + verify(rates).page(new MonthlyExchangeRateDtos.Query(2,1,null,null,"ACTIVE")); + assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "INACTIVE"), "1", first.snapshotToken()))); + assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", new ExportBatchRequest("t","0","PLATFORM","other",1,Map.of("status","ACTIVE"),"1",first.snapshotToken(),1))); + assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", new ExportBatchRequest("t","0","PLATFORM","u",2,Map.of("status","ACTIVE"),"1",first.snapshotToken(),1))); + } + + @Test void retainsOrderStageFalseAndRelabelFiltersWithoutExportingInternalIds() { + var order = new ObjectMapper().findAndRegisterModules().convertValue(Map.of("id", "9007199254740993", "orderNo", "LL-1", "currentStatus", "PENDING_ASSIGNMENT", "abnormal", false, "detained", true), com.baiyue.lastleg.dto.LastlegOrderSummary.class); + when(orders.page(isNull(), any(), eq("RL-1"))).thenReturn(new PageResult<>(List.of(order),1,1,1,1)); + var result = service.batch("LAST_MILE_ORDER_LIST", request(Map.of("detained",false,"sailingStage","BEFORE_DEPARTURE","relabelNo","RL-1"),null,null)); + verify(orders).page(isNull(), argThat(q -> Boolean.FALSE.equals(q.detained()) && q.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE), eq("RL-1")); + assertEquals("否", result.rows().get(0).get("abnormal")); + assertEquals("是", result.rows().get(0).get("detained")); + assertEquals("待分配", result.rows().get(0).get("currentStatus")); + assertTrue(result.columns().stream().noneMatch(c -> c.key().equals("id") || c.key().equals("customerId"))); + } + + @Test void keepsCustomerScopeAndLedgerCustomerFilter() { + when(inventory.ledgers(eq("200"), any())).thenReturn(new PageResult<>(List.of(),1,1,0,0)); + service.batch("OVERSEAS_INVENTORY_LEDGER", request(Map.of("customerId","200","customerName","客户"),null,null)); + verify(inventory).ledgers(eq("200"), argThat(q -> "客户".equals(q.customerName()))); + } + + @Test void defaultsCostVisibilityClosed() { + when(operations.report(any(), eq(false))).thenReturn(new TripOperationsModels.Result(new PageResult<>(List.of(),1,1,0,0),null,null)); + service.batch("TRIP_OPERATIONS_LIST",request(Map.of("from","2026-10-01T00:00:00Z","to","2026-10-08T00:00:00Z"),null,null)); + verify(operations).report(any(), eq(false)); + } + + @Test void rejectsScopeInvalidParametersAndUntrustedInternalCalls() { + assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",new ExportBatchRequest("t","1","MERCHANT","u",1,Map.of(),null,null,1))); + assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",request(Map.of("unknown","x"),null,null))); + assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",request(Map.of(),"1",null))); + assertThrows(ResponseStatusException.class, () -> new LastlegExportProviderController(new InternalServiceTokenGuard("expected"),service).batch("wrong","LAST_MILE_ORDER_LIST",request(Map.of(),null,null))); + verifyNoInteractions(orders); + } + + private ExportBatchRequest request(Map p,String cursor,String snapshot) { + return new ExportBatchRequest("t","0","PLATFORM","u",1,p,cursor,snapshot,1); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java index 591993cbb..b04f74710 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java @@ -59,9 +59,9 @@ class FleetQuotePersistenceTest { service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row))); assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价"); } - @Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() { + @Test void deletePreservesHistoryAndBusinessText() { var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO))); - assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2"); + assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).records().get(0).details().originCity()).isEqualTo("=2+2"); service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero(); assertThat(service.history(created.quoteNo())).hasSize(2); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java index 946c14a87..25a81baf9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java @@ -36,22 +36,50 @@ class FleetCapacityOfferServiceTest { @AfterEach void clear(){SecurityContextHolder.clearContext();} @Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){ var rows=List.of(offer(1),offer(2));var page=new Page(3,20,2000);page.setRecords(rows); - when(offers.pageOffers(any(),any(),eq(true))).thenReturn(page); + when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(page); for(var row:rows){var trip=trip(row.getTripNo());when(trips.findByNo(row.getTripNo())).thenReturn(trip); when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("ONT8",null,"FBA:ONT8"))); when(execution.capacityPlan(row.getTripNo())).thenReturn(new FleetExecutionModels.Planning(row.getTripNo(),1L,List.of(),"hash",null,false,"计划待核对",List.of()));} - var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null); + var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null,null); var result=service.page(query); assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2); - var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true)); + assertThat(result.records()).allMatch(FleetCapacityOfferModels.Offer::canCreateInquiry).allMatch(value -> !value.canConfirm()); + var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); assertThat(captor.getValue().getCurrent()).isEqualTo(3);assertThat(captor.getValue().getSize()).isEqualTo(20); verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips); } @Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){ - when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101)); - assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null))).hasMessageContaining("100"); + when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(new Page<>(1,100,101)); + assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null,null))).hasMessageContaining("100"); verifyNoInteractions(execution,trips); } + @Test void historyScopeRequiresManagementAndDefaultScopeIsAvailable(){ + var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null); + assertThat(query.scope()).isEqualTo("AVAILABLE"); + var history=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,"ALL"); + assertThatThrownBy(()->service.page(history)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(offers); + var user=new AuthenticatedUser(101L,0L,0L,101L,"admin","ADMIN","WEB",Set.of(FleetCapacityOfferService.MANAGE)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.MANAGE)))); + when(offers.pageOffers(any(),same(query),eq(true),any())).thenReturn(new Page<>(1,20,0)); + service.page(query); + verify(offers).pageOffers(any(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); + when(offers.pageOffers(any(),same(history),eq(false),any())).thenReturn(new Page<>(1,20,0)); + assertThat(service.page(history).records()).isEmpty(); + verify(offers).pageOffers(any(),same(history),eq(false),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); + } + @Test void availableSqlFiltersBeforePaginationWithoutRequiringKnownCapacity() throws Exception { + var method=Arrays.stream(FleetCapacityOfferMapper.class.getMethods()).filter(m->m.getName().equals("pageOffers")).findFirst().orElseThrow(); + String script=String.join("\n",method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + var sql=new org.apache.ibatis.scripting.xmltags.XMLLanguageDriver().createSqlSource(new org.apache.ibatis.session.Configuration(),script,Map.class); + var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null); + var parameters=new HashMap();parameters.put("q",query);parameters.put("availableOnly",true);parameters.put("now",LocalDateTime.ofInstant(now,ZoneOffset.UTC)); + String active=sql.getBoundSql(parameters).getSql().replaceAll("\\s+"," "); + assertThat(active).contains("o.status='OPEN'", "t.status='PLANNED'", "o.cutoff_at > ?", "o.cutoff_at <= t.planned_departure_at"); + assertThat(active).doesNotContain("planned_load_json", "remaining", "NOW()"); + parameters.put("availableOnly",false); + assertThat(sql.getBoundSql(parameters).getSql()).doesNotContain("o.cutoff_at", "t.status='PLANNED'"); + } @Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){ var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN"); assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java new file mode 100644 index 000000000..0fff22d9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class FleetDispatchWorkflowQueryTest { + @Test + void batchesReviewsAndKeepsCustomerDispatchDecisionsIndependent() { + var bindings = mock(FleetTripDispatchMapper.class); + var trips = mock(FleetTripMapper.class); + var first = new FleetTripDispatch(); first.setDispatchNo("PC-A"); first.setTripId(1L); first.setTripNo("TR-A"); + first.setReviewJson("{\"status\":\"REJECTED\",\"reason\":\"货物需重新核对\"}"); + var second = new FleetTripDispatch(); second.setDispatchNo("PC-B"); second.setTripId(1L); second.setTripNo("TR-A"); + var trip = new FleetTripEntity(); trip.setId(1L); + trip.setLoadingReviewJson("{\"status\":\"APPROVED\",\"reason\":\"审核通过\"}"); + when(bindings.findByDispatchNos(List.of("PC-A", "PC-B", "PC-C"))).thenReturn(List.of(first, second)); + when(trips.selectBatchIds(any())).thenReturn(List.of(trip)); + var query = new FleetDispatchWorkflowQuery(bindings, trips, new ObjectMapper().findAndRegisterModules()); + var result = query.read(List.of("PC-A", "PC-B", "PC-C")); + assertEquals("REJECTED", result.get("PC-A").dispatchReview().status()); + assertEquals("货物需重新核对", result.get("PC-A").dispatchReview().reason()); + assertEquals("PENDING", result.get("PC-B").dispatchReview().status()); + assertEquals("APPROVED", result.get("PC-B").loadingReview().status()); + assertFalse(result.containsKey("PC-C")); + verify(trips).selectBatchIds(List.of(1L)); + verifyNoMoreInteractions(trips); + assertTrue(query.read(List.of()).isEmpty()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 3a68929af..615002116 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -94,7 +94,8 @@ class FleetTripPersistenceTest { "ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); assertThat(match.find()).as(table).isTrue(); - jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + jdbc.execute(match.group().replace(" JSON", " LONGTEXT") + .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); } var configuration = new MybatisConfiguration(); for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index dd20d98ac..0baf7c482 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -60,32 +60,9 @@ class InventoryQueryControllerTest { verify(service).dashboard(eq(null), eq(query)); } - @Test - void balanceExportIsMaterializedBeforeReturningResponse() { - byte[] csv = "\uFEFFcustomerId\r\n10001\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); - when(service.exportBalances("10001", query)).thenReturn(csv); - ResponseEntity response = controller.exportBalances(user(0L, "PLATFORM"), "10001", query); - org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody()); - org.junit.jupiter.api.Assertions.assertEquals("text/csv;charset=UTF-8", - response.getHeaders().getContentType().toString()); - verify(service).exportBalances("10001", query); - } - @Test - void ledgerExportIsMaterializedBeforeReturningResponse() { - InventoryQueryRequests.LedgerQuery ledgerQuery = new InventoryQueryRequests.LedgerQuery( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null); - byte[] csv = "\uFEFFoccurredAt\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); - when(service.exportLedgers("10001", ledgerQuery)).thenReturn(csv); - - ResponseEntity response = controller.exportLedgers( - user(0L, "PLATFORM"), "10001", ledgerQuery); - - org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody()); - verify(service).exportLedgers("10001", ledgerQuery); - } @Test void unauthenticatedPositionDetailIsRejected() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index 3a8053b2b..01aa1685f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -198,21 +198,21 @@ class InventoryQueryServiceTest { InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, java.time.LocalDateTime.of(2026, 8, 18, 0, 0), - java.time.LocalDateTime.of(2026, 8, 17, 0, 0)); + java.time.LocalDateTime.of(2026, 8, 17, 0, 0), null); assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query)); verify(mapper, never()).countLedgers(any(), any()); } @Test - void balanceExportUsesInboundOrderPackageColumnsOnly() { - InventoryQueryRequests.BalanceQuery query = balanceQuery(null); - when(mapper.sourceBalances("100", query, 0, 10_000)).thenReturn(List.of()); - - String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); - - assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages")); - assertFalse(csv.contains("Quantity")); + void balanceExportPagesSourceRowsInsteadOfGroupedBalances() { + var query = balanceQuery(null); + when(mapper.countSourceBalances("100", query)).thenReturn(201L); + when(mapper.sourceBalances("100", query, 0, 20)).thenReturn(List.of()); + var page = service.exportBalancePage("100", query); + assertEquals(201L, page.total()); + verify(mapper).sourceBalances("100", query, 0, 20); + verify(mapper, never()).countBalances(any(), any()); } private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { @@ -224,7 +224,7 @@ class InventoryQueryServiceTest { InventoryQueryRequests.BalanceQuery balances = new InventoryQueryRequests.BalanceQuery( null, null, null, null, null, null, null, null, null, null, null, null, null, null); InventoryQueryRequests.LedgerQuery ledgers = new InventoryQueryRequests.LedgerQuery( - null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); assertEquals(1L, balances.page()); assertEquals(20L, balances.size()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java index fee858866..1f5c7b123 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java @@ -18,7 +18,7 @@ class OverseasMasterDataOpenApiContractTest { .contains("PartnerWarehouseOption:") .contains("/api/platform/v1/last-leg/master-data/exchange-rates:") .contains("/api/platform/v1/last-leg/master-data/vehicles:") - .contains("platform:overseas-master-data:exchange-rate:export") + .doesNotContain("/api/platform/v1/last-leg/master-data/exchange-rates/export:") .contains("platform:overseas-master-data:vehicle:status") .contains("不包含派送履约或车辆更换历史") .doesNotContain("deliveryOrders:") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java index cf72432f1..9c4b26e4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java @@ -24,6 +24,6 @@ class PlatformMonthlyExchangeRateControllerContractTest { assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create"); assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit"); assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status"); - assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export"); + assertThat(authorities).doesNotContainKey("export"); // Export is served by the File download center. } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java index 63595bb05..7bea3afb4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java @@ -27,7 +27,7 @@ class PlatformOverseasQuoteControllerContractTest { assertThat(authorities.get("create")).contains("platform:overseas-master-data:quote:create"); assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit"); assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status"); - assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export"); + assertThat(authorities).doesNotContainKey("export"); Method options = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods()) .filter(method -> method.getName().equals("warehouseOptions")) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java index ae1a8d3e1..f0a956f00 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java @@ -23,6 +23,6 @@ class PlatformVehicleControllerContractTest { assertThat(authorities.get("create")).contains("platform:overseas-master-data:vehicle:create"); assertThat(authorities.get("update")).contains("platform:overseas-master-data:vehicle:edit"); assertThat(authorities.get("status")).contains("platform:overseas-master-data:vehicle:status"); - assertThat(authorities.get("export")).contains("platform:overseas-master-data:vehicle:export"); + assertThat(authorities).doesNotContainKey("export"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java index 7a63467e7..520a39086 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -63,12 +63,7 @@ class VehicleApplicationServiceTest { org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()).create(org.mockito.ArgumentMatchers.any()); } - @Test void exportUsesPlateAsBusinessIdentifierAndIncludesAllCapacityDimensions() { - when(repository.list(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(vehicle(3, 1000))); - String csv = new String(service.exportCsv(new VehicleModels.Query(1, 20, null, null, null, null)), java.nio.charset.StandardCharsets.UTF_8); - assertThat(csv).contains("最大托位,最大载重(kg),可用容积(m³)").contains("\"30\",\"18000\",\"90\"") - .doesNotContain("车辆ID"); - } + private static VehicleModels.Update update(long version, long mileage) { return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 6f99457f8..fba5b4a14 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -136,8 +136,8 @@ class RelabelContractTest { void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception { String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) .replaceAll("\\s+", "").toUpperCase(); - assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0")); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0")); assertTrue(baseline.contains("STATUSIN('MERGED','DISCARDED')ANDSKU_TYPES=0ANDTOTAL_QUANTITY=0")); - assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')")); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java new file mode 100644 index 000000000..b8705c22b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ReturnRelabelDeliveryTest { + @Test void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination() { + var service = new RelabelApplicationService(); + var items = mock(RelabelItemMapper.class); + var cartons = mock(ReturnInventoryCartonMapper.class); + var deliveries = mock(DeliveryOrderMapper.class); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveries); + ReflectionTestUtils.setField(service, "outboundOrders", mock(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(service, "outboundItems", mock(OutboundPalletItemMapper.class)); + ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class)); + ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1"); + application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod("EXPRESS"); + var a = source(10L, "A"); var b = source(11L, "B"); + when(items.selectList(any())).thenReturn(List.of(a, b)); + for (var item : List.of(a, b)) { + var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId()); + carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING"); + when(cartons.lockId(carton.getId())).thenReturn(carton); + } + ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now()); + verify(deliveries, times(2)).insert(any(DeliveryOrder.class)); + verify(deliveries).insert(argThat((DeliveryOrder d) -> "A".equals(d.getDestinationWarehouseCode()) + && d.getPackageCount() == 1 && "WAIT_DELIVERY".equals(d.getStatus()) + && application.getOutboundOrderNo().equals(d.getOutboundNo()))); + verify(deliveries).insert(argThat((DeliveryOrder d) -> "B".equals(d.getDestinationWarehouseCode()) && d.getPackageCount() == 1)); + } + private RelabelItem source(long id, String destination) { + var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(id); + item.setNotifiedPackageCount(1); item.setSourceCartonMark("RC" + id + "#1-1"); + item.setDestinationFbaWarehouseCode(destination); return item; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index da1149160..099a453da 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -50,6 +50,9 @@ class ReturnInventoryServiceTest { @Mock LastlegOrderNoGenerator numbers; @Mock ReturnOrderMapper orders; @Mock ReturnReceiptMapper receipts; + @Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper packingTrace; + @Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingPlanMapper packingPlans; + @Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper orderItems; @Mock com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets; private ReturnInventoryService service; @@ -66,6 +69,62 @@ class ReturnInventoryServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "receiptMapper", receipts); ReflectionTestUtils.setField(service, "physicalPallets", physicalPallets); + ReflectionTestUtils.setField(service, "packingTrace", packingTrace); + ReflectionTestUtils.setField(service, "packingPlans", packingPlans); + ReflectionTestUtils.setField(service, "orderItems", orderItems); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + } + + @Test + void platformPlanNumberFilterIsTrimmedAndUsedForCountAndPage() { + when(packingPlans.countPlatform(null, "PLAN-1", "C1", "W1", "SKU1")).thenReturn(1L); + when(packingPlans.listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L)).thenReturn(List.of()); + var result = service.platformPackingPlans(new ReturnInventoryRequests.PackingPlanQuery(2, 20, null, " PLAN-1 ", " C1 ", " W1 ", " SKU1 ")); + assertThat(result.total()).isEqualTo(1L); + verify(packingPlans).listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L); + } + + @Test + void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() { + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(1L); carton.setCustomerId("CUSTOMER-A"); carton.setCartonMark("TARGET-1"); carton.setStatus("AVAILABLE"); + when(cartons.findCustomerMark("CUSTOMER-A", "TARGET-1")).thenReturn(carton); + when(cartonItems.listCarton(1L)).thenReturn(List.of()); + var history = new com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory("PLAN-1", "SOURCE-1", "TARGET-1", "RETURN-1", "SKU-1", 2, java.time.LocalDateTime.now()); + when(packingTrace.listCartonHistory("CUSTOMER-A", "TARGET-1")).thenReturn(List.of(history)); + assertThat(service.carton("CUSTOMER-A", "TARGET-1").mergeHistory()).containsExactly(history); + assertThrows(ResponseStatusException.class, () -> service.carton("CUSTOMER-B", "TARGET-1")); + } + + @Test + void packingSourcesBatchItemsWithinCustomerWarehouseAndSubtractReservations() { + ReturnInventoryCarton first = new ReturnInventoryCarton(); + first.setId(1L); first.setCartonMark("PARCEL-1"); first.setSourceUnitType("PARCEL"); first.setSourceReturnNo("RETURN-1"); + ReturnInventoryCarton second = new ReturnInventoryCarton(); + second.setId(2L); second.setCartonMark("BOX-2"); second.setSourceUnitType("BOX"); + ReturnInventoryCartonItem item = new ReturnInventoryCartonItem(); + item.setCartonId(1L); item.setSkuCode("SKU-1"); item.setQuantity(9L); item.setPackingReservedQuantity(7L); + when(cartons.platformCount("customer", "warehouse", null, null, "AVAILABLE", null)).thenReturn(2L); + when(cartons.platformPage("customer", "warehouse", null, null, "AVAILABLE", null, 0L, 200L)).thenReturn(List.of(first, second)); + when(cartonItems.listCartons(List.of(1L, 2L))).thenReturn(List.of(item)); + var response = service.platformPackingSourcePage(new ReturnInventoryRequests.PlatformQuery(1, 200, "customer", "warehouse", null, null, null, null)); + assertThat(response.total()).isEqualTo(2); + assertThat(response.records().get(0).sourceUnitType()).isEqualTo("PARCEL"); + assertThat(response.records().get(0).items().get(0).availableQuantity()).isEqualTo(2); + assertThat(response.records().get(1).items()).isEmpty(); + verify(cartonItems).listCartons(List.of(1L, 2L)); + verify(cartonItems, never()).listCarton(anyLong()); + verifyNoInteractions(balances); + } + + @Test + void emptyPackingSourcesSkipItemQueriesAndRequireCustomerScope() { + when(cartons.platformCount("customer", "warehouse", null, null, "AVAILABLE", null)).thenReturn(0L); + var response = service.platformPackingSourcePage(new ReturnInventoryRequests.PlatformQuery(1, 200, "customer", "warehouse", null, null, null, null)); + assertThat(response.records()).isEmpty(); + verifyNoInteractions(cartonItems); + assertThrows(ResponseStatusException.class, () -> service.platformPackingSourcePage( + new ReturnInventoryRequests.PlatformQuery(1, 200, null, "warehouse", null, null, null, null))); } @Test @@ -82,7 +141,8 @@ class ReturnInventoryServiceTest { when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton); when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml(); - assertThat(html).contains(" created = service.createReceiptCartons(order, receipt, + List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 5)), java.util.Map.of("SKU-A", 2L)); + + assertThat(created).hasSize(2); + assertThat(created).extracting(ReturnInventoryCarton::getCartonMark) + .allMatch(mark -> mark.matches("RC[A-F0-9]{16}#2-[12]")) + .doesNotHaveDuplicates(); + assertThat(created.get(0).getCartonMark()).endsWith("#2-1"); + assertThat(created.get(1).getCartonMark()).endsWith("#2-2"); + assertThat(created).extracting(ReturnInventoryCarton::getTotalQuantity).containsExactly(3L, 2L); + assertThat(created).extracting(ReturnInventoryCarton::getPalletCode).containsOnly("PAL-RETURN-1"); + verify(cartons, org.mockito.Mockito.times(2)).insert(any(ReturnInventoryCarton.class)); + } + @Test void mergeMovesItemsBetweenCartonsWithoutReducingAvailableQuantity() { ReturnOrder order = completedOrder(); @@ -133,6 +219,7 @@ class ReturnInventoryServiceTest { source.setSourceBatchNo("RB-1"); source.setSourceReturnNo("RT-1"); source.setStatus(ReturnInventoryService.AVAILABLE); + source.setSourceUnitType("PARCEL"); source.setSkuTypes(2); source.setTotalQuantity(4L); ReturnInventoryCartonItem skuA = item(41L, "SKU-A", 2L); @@ -140,12 +227,10 @@ class ReturnInventoryServiceTest { when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(order)); when(receipts.listOrder(11L)).thenReturn(List.of(receipt)); - when(cartons.findReceipt(21L)).thenReturn(source); + when(cartons.listReceipt(21L)).thenReturn(List.of(source)); when(cartons.lockMark("RC-SOURCE")).thenReturn(source); when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB)); when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED"); - when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString())) - .thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(91L, "PLT-MERGED", 42L, null, "EMPTY", null, 0)); when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L), @@ -162,10 +247,10 @@ class ReturnInventoryServiceTest { verify(cartons).insert(target.capture()); assertThat(target.getValue().getSourceBatchNo()).isEqualTo("RB-1"); assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1"); - assertThat(target.getValue().getPalletCode()).isEqualTo("PLT-MERGED"); - ArgumentCaptor businessKey = ArgumentCaptor.forClass(String.class); - verify(physicalPallets).ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), businessKey.capture()); - assertThat(businessKey.getValue()).matches("RETURN-MERGE:[0-9a-f]{64}"); + assertThat(target.getValue().getPalletCode()).isNull(); + verifyNoInteractions(physicalPallets); + verify(packingTrace, org.mockito.Mockito.times(2)).insert(anyLong(), anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), any()); + } @Test @@ -198,8 +283,6 @@ class ReturnInventoryServiceTest { ReturnInventoryCarton locked = prepareConcurrentSource(); when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); - when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString())) - .thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(92L, "PLT-TARGET", 42L, null, "EMPTY", null, 0)); when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)); assertThat(locked.getTotalQuantity()).isZero(); @@ -264,16 +347,97 @@ class ReturnInventoryServiceTest { return operation; } + @Test + void skuPackingNeverConsumesPhysicalBoxInventory() { + ReturnInventoryCarton source = prepareConcurrentSource(); + source.setSourceUnitType("BOX"); + // The candidate snapshot is a parcel; the locked physical source must be rechecked. + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "操作员", 42L), mergeRequest(1))); + assertThat(error.getStatusCode().value()).isEqualTo(409); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test + void samePrefixSkusHaveIndependentShortBoxMarksAndTheirOwnSequences() { + String a = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 1L); + String b = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-B", 2L, 1L); + String second = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 2L); + assertThat(a).isNotEqualTo(b).matches("RC[A-F0-9]{16}#2-1"); + assertThat(second).isEqualTo(a.substring(0, a.lastIndexOf('-') + 1) + "2"); + } + + @Test + void packingLabelsCountBoxesPerSkuInsteadOfCountingAllSkusTogether() { + var boxA = new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("A", 2)), null, null, null, null); + var boxB = new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("B", 2)), null, null, null, null); + List marks = ReflectionTestUtils.invokeMethod(service, "packingMarks", List.of(boxA, boxB, boxA)); + assertThat(marks).hasSize(3).doesNotHaveDuplicates(); + assertThat(marks.get(0)).endsWith("#2-1"); assertThat(marks.get(1)).endsWith("#1-1"); + assertThat(marks.get(2)).isEqualTo(marks.get(0).replace("#2-1", "#2-2")); + } + + @Test + void reservedSkuQuantityCannotBeConsumedByDirectPacking() { + prepareConcurrentSource(); + var line = item(41L, "SKU-A", 2); line.setPackingReservedQuantity(2L); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(line)); + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "操作员", 42L), mergeRequest(1))); + assertThat(error.getStatusCode().value()).isEqualTo(409); + verify(cartonItems, never()).deleteById(anyLong()); + verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class)); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test + void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() { + var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); + source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); + source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL"); + var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L); + when(orders.lockNo("RT-1")).thenReturn(completedOrder()); + when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source)); + when(cartons.lockMark("RC-SOURCE")).thenReturn(source); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(line)); + when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line); + when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1); + when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1"); + var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU", + List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key"); + var created = service.createPackingPlan("platform", request); + assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3); + var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class); + verify(packingPlans).insert(captor.capture()); + when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue()); + assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED"); + assertThat(line.getPackingReservedQuantity()).isEqualTo(1); + verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L)); + } + + @Test + void boxPlanRequiresExplicitSourceMarksInsteadOfOrderScopeOnly() { + when(orders.lockNo("RT-1")).thenReturn(completedOrder()); + var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "BOX", + List.of(new ReturnInventoryRequests.PlanBox(List.of(), null, null, null, null)), null, "box-key"); + var error = assertThrows(ResponseStatusException.class, () -> service.createPackingPlan("platform", request)); + assertThat(error.getStatusCode().value()).isEqualTo(400); + verifyNoInteractions(cartonItems, balances); + } + private ReturnInventoryCarton prepareConcurrentSource() { ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L); ReturnInventoryCarton snapshot = new ReturnInventoryCarton(); snapshot.setId(31L); snapshot.setCartonMark("RC-SOURCE"); snapshot.setStatus(ReturnInventoryService.AVAILABLE); + snapshot.setSourceUnitType("PARCEL"); + snapshot.setSourceReturnNo("RT-1"); ReturnInventoryCarton locked = new ReturnInventoryCarton(); locked.setId(31L); locked.setCartonMark("RC-SOURCE"); locked.setStatus(ReturnInventoryService.AVAILABLE); + locked.setSourceUnitType("PARCEL"); locked.setCustomerId("10001"); locked.setOverseasWarehouseId("42"); locked.setSourceBatchNo("RB-1"); locked.setSourceReturnNo("RT-1"); when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(completedOrder())); when(receipts.listOrder(11L)).thenReturn(List.of(receipt)); - when(cartons.findReceipt(21L)).thenReturn(snapshot); + when(cartons.listReceipt(21L)).thenReturn(List.of(snapshot)); when(cartons.lockMark("RC-SOURCE")).thenReturn(locked); return locked; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 7e9d1415e..019a0db3b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -39,6 +39,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -365,8 +366,7 @@ class ReturnReceiptServiceTest { var first = service.confirm(identity, request); ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(savedReceipt.capture()); - when(receipts.findKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue()); - when(orders.selectById(1L)).thenReturn(order); + when(receipts.lockKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue()); var replay = service.confirm(identity, request); assertTrue(first.fullyMatched()); @@ -382,7 +382,6 @@ class ReturnReceiptServiceTest { void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1); ReturnOrderItem line = item(11L, "SKU-A", 2, 1); - when(receipts.findKey("operator-1", "key-over")).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(List.of(line)); @@ -397,7 +396,6 @@ class ReturnReceiptServiceTest { @Test void rejectsAnotherWarehousesOrderBeforeLockingSkuOrWritingInventory() { ReturnOrder order = order("RT-1", "11", ReturnReceiptService.PENDING_RECEIPT, 2, 0); - when(receipts.findKey("operator-1", "key-cross")).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, @@ -418,7 +416,7 @@ class ReturnReceiptServiceTest { service.confirm(identity, original); ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(savedReceipt.capture()); - when(receipts.findKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue()); + when(receipts.lockKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue()); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1", @@ -430,7 +428,6 @@ class ReturnReceiptServiceTest { } private void stubNewReceipt(ReturnOrder order, List items, String cartonMark) { - when(receipts.findKey(eq("operator-1"), anyString())).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(items); when(orderItems.listOrder(1L)).thenReturn(items); @@ -454,6 +451,10 @@ class ReturnReceiptServiceTest { var result = service.confirm(identity, request); + verify(receipts, never()).findKey(anyString(), anyString()); + var reads = org.mockito.Mockito.inOrder(orders, receipts); + reads.verify(orders).lockNo("RT-1"); + reads.verify(receipts).lockKey("operator-1", "concurrent-key"); assertEquals("RR-COMMITTED", result.receiptNo()); assertEquals("RB-COMMITTED", result.receiptBatchNo()); assertEquals(1, result.receivedQuantity()); @@ -498,8 +499,25 @@ class ReturnReceiptServiceTest { return item; } + @Test + void combinedLabelsKeepPhysicalPageStyleAndOneSectionPerBox() { + String first = "
RC#2-1
"; + String second = first.replace("RC#2-1", "RC#2-2"); + String result = ReflectionTestUtils.invokeMethod(service, "combineLabels", List.of( + new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.CartonLabel("RC#2-1", first), + new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.CartonLabel("RC#2-2", second))); + assertThat(result).contains("@page{size:100mm 100mm}", ".barcode{height:15mm}", "RC#2-1", "RC#2-2", ".return-label:last-child"); + assertThat(result.split("
", -1)).hasSize(3); + assertThat(result.split("@page", -1)).hasSize(2); + } + private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode, String key, List items) { - return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key); + List boxed = items.stream() + .map(item -> new PdaReturnReceiptRequests.Item(item.skuCode(), item.receivedQuantity(), 1L)).toList(); + // These focused tests exercise receipt/idempotency behavior at the + // carton level. Pallet receipts have a separate multi-carton contract. + return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key, + "BOX", palletCode, null, null, null, null, 1L); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java new file mode 100644 index 000000000..d6b55754c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderRequests.SailingStage; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LastlegOrderSailingStageTest { + @Test + void filtersBeforePaginationByActualDepartureAndKeepsOtherTypesOnlyInAll() throws Exception { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "sailing-test"), LastlegOrder.class); + try (var db = DriverManager.getConnection("jdbc:h2:mem:sailing;MODE=MySQL")) { + var sql = db.createStatement(); + sql.execute("CREATE TABLE ll_order (id BIGINT, customer_id VARCHAR, order_type VARCHAR, source_region VARCHAR, " + + "source_container_booking_id VARCHAR, manual_node_override BOOLEAN, current_status VARCHAR)"); + sql.execute("CREATE TABLE ll_container_shipping_gate (container_booking_id VARCHAR, node_sequence INT, actual_at TIMESTAMP)"); + sql.execute("CREATE TABLE ll_order_manual_node (order_id BIGINT, customer_id VARCHAR, node_code VARCHAR, node_at TIMESTAMP)"); + sql.execute("INSERT INTO ll_container_shipping_gate VALUES ('loaded',30,CURRENT_TIMESTAMP)," + + "('departed',40,CURRENT_TIMESTAMP),('arrived',60,CURRENT_TIMESTAMP),('estimated',40,NULL)"); + sql.execute("INSERT INTO ll_order VALUES " + + "(1,'A','FIRST_LEG','FIRSTLEG',NULL,NULL,'PENDING_ASSIGNMENT')," + + "(2,'A','FIRST_LEG','FIRSTLEG','loaded',FALSE,'INBOUND_PLANNED')," + + "(3,'A','FIRST_LEG','FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')," + + "(4,'A','FIRST_LEG','FIRSTLEG','arrived',FALSE,'INBOUND_COMPLETED')," + + "(5,'A','FIRST_LEG','FIRSTLEG','estimated',FALSE,'PENDING_ASSIGNMENT')," + + "(6,'A','FIRST_LEG','FIRSTLEG','arrived',TRUE,'PENDING_ASSIGNMENT')," + + "(7,'A','FIRST_LEG','FIRSTLEG','loaded',TRUE,'PENDING_ASSIGNMENT')," + + "(8,'B','FIRST_LEG','FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')," + + "(9,'A','FIRST_LEG','CUSTOMER','departed',FALSE,'PENDING_ASSIGNMENT')"); + sql.execute("INSERT INTO ll_order_manual_node VALUES (6,'A','ESTIMATED_DEPARTURE',CURRENT_TIMESTAMP)," + + "(6,'A','ACTUAL_DEPARTURE',NULL),(6,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)," + + "(7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); + int id = 10; + for (String type : List.of("ONE_CLICK_DROPSHIP", "RELABEL_APPLICATION", "WAREHOUSE_TRANSFER", + "LOCAL_LOOSEBOARD", "RETURN_RELABEL", "CARGO_DISCARD")) { + try (var insert = db.prepareStatement("INSERT INTO ll_order VALUES (?,'A',?,'FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')")) { + insert.setInt(1, id++); insert.setString(2, type); insert.executeUpdate(); + } + } + assertEquals(List.of(1L, 2L, 5L, 6L), find(db, null, SailingStage.BEFORE_DEPARTURE, null)); + assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null)); + assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null)); + assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT")); + assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded. + } + } + + private List find(java.sql.Connection db, String customerId, SailingStage stage, String status) throws Exception { + var query = new LastlegOrderRequests.Query(1L, 20L, null, null, null, null, null, null, + status, null, null, null, null, null, null, null, stage); + LambdaQueryWrapper filter = ReflectionTestUtils.invokeMethod( + new LastlegOrderService(), "filter", customerId, query); + String where = filter.getSqlSegment(); + var matcher = Pattern.compile("#\\{ew.paramNameValuePairs.([^}]+)}").matcher(where); + List parameters = new ArrayList<>(); + StringBuilder boundSql = new StringBuilder(); + while (matcher.find()) { + parameters.add(filter.getParamNameValuePairs().get(matcher.group(1))); + matcher.appendReplacement(boundSql, "?"); + } + matcher.appendTail(boundSql); + try (var statement = db.prepareStatement("SELECT id FROM ll_order" + + (where.isBlank() ? "" : " WHERE " + boundSql) + " ORDER BY id")) { + for (int i = 0; i < parameters.size(); i++) statement.setObject(i + 1, parameters.get(i)); + List ids = new ArrayList<>(); + try (var rows = statement.executeQuery()) { while (rows.next()) ids.add(rows.getLong(1)); } + return ids; + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 4e8b1963c..8de551aaf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -375,7 +375,7 @@ class LastlegOrderServiceTest { @Test void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, - null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null); + null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); @@ -389,30 +389,9 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } - @Test - void exportUsesChineseBooleanAndMainStatusLabels() { - LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); - order.setAbnormal(false); - order.setDetained(true); - var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 1000); - resultPage.setRecords(List.of(order)); - resultPage.setTotal(1); - when(orders.selectPage(any(), any())).thenReturn(resultPage); - String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, - null, null, null, null)), StandardCharsets.UTF_8); - assertTrue(csv.contains("\"否\",\"是\"")); - assertTrue(csv.contains("\"待分配\"")); - assertFalse(csv.contains("\"false\"")); - assertFalse(csv.contains("\"PENDING_ASSIGNMENT\"")); - assertEquals("草稿", LastlegOrderStatus.labelOf("DRAFT")); - assertEquals("审核通过", LastlegOrderStatus.labelOf("APPROVED")); - assertEquals("已分配目的仓", LastlegOrderStatus.labelOf("DESTINATION_ASSIGNED")); - assertEquals("已生成入库计划", LastlegOrderStatus.labelOf("INBOUND_PLANNED")); - assertEquals("处理中", LastlegOrderStatus.labelOf("PROCESSING")); - } + @Test void pageWarehouseFilterMatchesFrozenWarehouseName() { @@ -553,7 +532,7 @@ class LastlegOrderServiceTest { new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"), new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1"))); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); var result = service.page(null, query); @@ -577,7 +556,7 @@ class LastlegOrderServiceTest { resultPage.setTotal(3); when(orders.selectPage(any(), any())).thenReturn(resultPage); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); var result = service.page(null, query); @@ -609,7 +588,7 @@ class LastlegOrderServiceTest { when(orders.selectPage(any(), any())).thenReturn(resultPage); var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, input, null, null, null, null, null)); + null, null, null, null, input, null, null, null, null, null, null)); ArgumentCaptor> filter = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); diff --git a/scripts/README.md b/scripts/README.md index 7d8447aad..1ac3ce273 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,12 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/return-relabel-truck-incremental-20261008.sql` 为本地退货换标排车增量,显式指向 `baiyue_iam` / `baiyue_lastleg`,适用于 MySQL 8。补齐管理员正式司机、车次与审核权限,以及车辆容量和正式车队表;不修改退货库存或业务单据。凭据从本机配置读取,可重复执行,失败保留原错误和业务现场;重新登录后核对权限快照。结构同步到基线及两个统一增量,权限由正式 IAM 权限种子维护。 + +`db/return-packing-incremental-20261008.sql` 为本地退货合箱增量,明确指向 `baiyue_iam` 和 `baiyue_lastleg`,适用于 MySQL 8,可重复执行。初始化平台管理员的库存管理权限,补齐精确预占、原退货单来源追溯、完成结果幂等快照和箱级库位字段,移除“一签收单只能一箱”的旧唯一索引;不修改退货、库存等业务数据。数据库凭据从本机环境配置读取;执行失败应保留原错误,修复后重跑,不清理验收业务单据。同批结构已同步到基线、海外统一增量与 `db/incremental.sql`。 +`fleet-capacity-pool-ui-fixture-gray.mjs` 仅准备本轮授权的 GRAY 拼车角色页面、报价引用和 PDA H5 验收数据,复用 TEST UI producer/core,默认 `--plan` 零网络。执行使用 `node --env-file= --env-file= scripts/fleet-capacity-pool-ui-fixture-gray.mjs --execute --environment GRAY --run-id <新批次> --warehouse-id <实际收货仓ID> --destination-warehouse-id --private-credentials-file <私有env目录直接子级的独占JSON绝对路径>`。保留实际 `CHONGCHONG_ENV=prod|gray`,配置沿 GRAY 拼车专项的 CHONGCHONG_ADMIN 与 JENKINS_*;强制生产身份只读reader和品牌transport,不接受producer/reader覆盖。只建两客户真实来货、自有18/30计划、4完整草稿、两个销售及最小manage调度;销售A另含quote:list,销售B不含。准备窗口按同一时刻固定为8小时截止、9小时后计划发车、10小时后派送预计到站和12小时后司机结束;LocalDateTime到站与Instant车次时间分别处理。保留本批唯一仓绑定PDA为ACTIVE供H5,私有文件标记用途/仓库,不自动停用;脚本会话仍注销。凭据首次写入前wx独占并fsync,报告 `reports/fleet-capacity-pool-ui-gray-<批次>/` 全部脱敏,使用共享写锁,未知结果停写且不自动重试或覆盖;不创建池商业单、不发车签收、不写SQL或部署。原TEST UI入口仍24草稿且自动停用PDA。直接离线保护为 `fleet-capacity-pool-ui-fixture-gray.test.mjs`,凭据独占及脱敏复用 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。 + +`fleet-capacity-pool-gray.mjs` 是明确 GRAY 的拼车专项入口,复用既有池全流程矩阵与两客户实货前置,不执行旧 FBA 下单、报关或装柜链。默认 `--plan` 零网络、零文件写入;执行必须传 `--execute --environment GRAY --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `。命令前使用 `node --env-file=<独立jenkins.env> --env-file=<明确gray-acceptance.env>`,要求 `CHONGCHONG_ENV=prod` 或 `gray`、`CHONGCHONG_ADMIN_API_BASE_URL`(统一 HTTPS 源站加 `/api`)、`CHONGCHONG_ADMIN_USERNAME/PASSWORD` 和 `JENKINS_URL/USER/API_TOKEN`;不改私有配置、不冒充 TEST、不使用 TEST agent。库存证据由 `createGrayEvidenceReaders` 校验生产容器、profile及数据库身份后只读取得。报告独占 `reports/fleet-pool-gray-<新批次>/`,沿共享 `.fleet-acceptance.lock` 串行执行;每次写入先持久记录原键与意图,结果未知停写,失败保留样本,不自动续跑或覆盖。结束停用本轮临时销售/调度/PDA账号、退出会话;合成客户、司机/车辆、报价、文件和业务样本留存,费用仍由唯一真实派送来源产生,不发起支付。Admin 通用 GRAY CLI 的 F40 白名单不变,专用 transport factory 只能创建空模块且无故障注入的灰度上下文。直接合同 `fleet-capacity-pool-gray.test.mjs` 验证环境隔离、品牌门禁和复用范围;原 TEST CLI 仍拒绝 GRAY。本入口不部署、不写业务 SQL,不将专项通过冒充全部页面验收。 + 拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 `fleet-capacity-pool-ui-fixture-test-env.mjs` 仅准备内部拼车池TEST页面验收数据,默认 `--plan` 零网络;实际命令沿 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,另必须显式传 `--private-credentials-file <用户.codex/env/chongchong-global目录直接子级的独占JSON绝对路径>`。通过明确TEST环境文件读取CHONGCHONG_ADMIN及Jenkins只读取证配置,创建两客户真实来货和计划车次、OPEN发布、两销售各12条不占位草稿及最小调度账号;停在可交互状态,不确认承运、不拣货发车、不声称全量验收。密码只在首次写入前独占创建的私有文件中保存,报告 `reports/fleet-capacity-pool-ui-<新批次>/` 不含密码/Token;失败保留现场和原请求,不自动重跑。退出脚本会话并停用自有fixture PDA,保留UI账号供浏览器验收;不部署、不写业务SQL、不删除业务样本。离线安全与作用域合同为 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。 @@ -94,6 +101,8 @@ Manifest 使用 `version:1, scope, revision, requirements:[{id,domain,level,chec `db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 +`db/recent-schema-incremental-20261008.sql`:2026-09-21 至 2026-10-08 近期结构增量单文件汇总,显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;包括 PDA 仓绑定、FBA 报价、墨家测量、换标普通索引、A0 标记、报关两文件、退货签收/箱级出库/箱唛总文件/合箱预占与追溯。用于已有基线业务库,MySQL 8.0.16+;逐列和逐索引判断,可重复执行,两个退货 CHECK 在单条 ALTER 中原子替换。无 SOURCE 依赖、凭据、业务 DML 或清理;操作者自行选择目标连接后将整个文件作为 SQL 脚本执行(不使用 --force),DDL 报错停止,核对末尾 READY/CHECK_REQUIRED 和各段结果。报告由 SQL 客户端日志保存,失败保留已成功 DDL,修复原因后可重跑;不自动连接或执行,不替代正式区域初始化入口。 + `db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 `db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。 @@ -186,6 +195,20 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" Client 全量、Client 入库测量与计费、待审核订单和批量待分柜均复用这套变量,不再维护各自的 Gateway、管理员或客户账号变量。外部文件必须保留在仓库之外,不得复制到项目目录、报告或提交记录。 +### 灰度环境 FBA 定向闭环 + +`admin-test-env-acceptance.mjs --environment gray` 显式支持临时灰度环境,当前仅开放 `--modules phase5-root-fulfillment` 及自动展开的本轮客户资质 fixture。该链会通过正式 HTTP 新建隔离客户、两箱根订单及分柜子来源,推进国内测量、装柜、报关、人工船节点、海外收货、排车、出库和签收;不是默认全量验收。默认全量中包含 TEST 专用故障注入和未经灰度副作用核对的模块,因此 gray 拒绝省略模块、附加模块、批量订单和卸柜故障开关,也不会创建 TEST fault executor。`--f40-tax-mode TAX_REFUND` 覆盖退税文件确认链;真实邮件和供应商自动查询不在此闭环的完成声明中。 + +明确的仓库外环境文件须配置 `ADMIN_GRAY_GATEWAY`(统一 HTTPS 源站,不带 `/api`)、`ADMIN_GRAY_USERNAME`、`ADMIN_GRAY_PASSWORD`、`ADMIN_GRAY_PDA_USERNAME`、`ADMIN_GRAY_PDA_PASSWORD`;可选 `ADMIN_GRAY_CUSTOMER_NAME`。gray 不读取 TEST Gateway、管理员或 PDA 凭据。F40 始终创建本轮客户,不需要传已有 merchant 账号。数据库只读取证使用 `JENKINS_URL`、`JENKINS_USER`、`JENKINS_API_TOKEN` 及 Jenkins 已登记的 production SSH/host/known-hosts/env 凭据,由 `gray-regression-evidence.mjs` 实现,不调用 TEST agent 或写业务 SQL。 + +运行前核对国内 PDA 绑定仓与 `--origin-warehouse-name` 一致,且目标环境已有有效渠道报价、订舱代理、库位/托盘及业务月份 ACTIVE 的 USD→CNY 月度汇率。汇率归 Lastleg `master-data/exchange-rates`,只读月份筛选为 `monthFrom`/`monthTo`;缺少时派送费用发布会被明确拒绝,脚本不自行沿用上月或修改共享汇率。海外 PDA 则从本轮两柜实际尾程来源解析唯一接柜仓,不按仓库列表顺序选择。 + +```powershell +node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acceptance.mjs --environment gray --modules phase5-root-fulfillment --f40-tax-mode TAX_REFUND --origin-warehouse-name "" --output "reports/<新批次>/gray-fba-flow.json" +``` + +运行前须具备该灰度环境写入授权、所选仓的 PDA 账号、正式主档和有效价格簿;只读取证预检失败时不进入业务写入。报告显式标注 gray、使用 `GRAY-API-` 资源标记,并列出未覆盖范围。失败保留本轮资源和原始报告,不自动重放未知结果、不删样本、不重置数据库;先核对失败阶段再决定续跑,新运行使用独立报告路径和新样本。本链通过不代表默认全量、页面视觉或外部供应商已验收。 + ## 脚本目录归属 脚本按作用域放置,不要求全部集中到根 `scripts/`: @@ -214,6 +237,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | + +`client-phase2-measurement-evidence.mjs` 为该专项内部取证助手,核对平台已冻结的 calculatedOuter 尺寸、体积和客户发布的计费外尺寸及规则快照;不使用当前客户配置重算历史快照。对应 `client-phase2-measurement-evidence.test.mjs`,没有独立 CLI。新样本先沿 Admin `inbound-fees` 依赖链准备,再用专项的 `--order-id` 指定本轮订单;保留原失败报告。 | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | | 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 | diff --git a/scripts/__pycache__/client-import-template-inspect.cpython-312.pyc b/scripts/__pycache__/client-import-template-inspect.cpython-312.pyc new file mode 100644 index 000000000..28be4be1c Binary files /dev/null and b/scripts/__pycache__/client-import-template-inspect.cpython-312.pyc differ diff --git a/scripts/__pycache__/order-submit-dependency-fault-proxy.cpython-311.pyc b/scripts/__pycache__/order-submit-dependency-fault-proxy.cpython-311.pyc new file mode 100644 index 000000000..ffb43d353 Binary files /dev/null and b/scripts/__pycache__/order-submit-dependency-fault-proxy.cpython-311.pyc differ diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 9d150880c..ede6f17cc 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'dashboard', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -16,6 +16,21 @@ const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 +export function transportFailureDetails(error) { + const causes = [] + const visited = new Set() + const symbol = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(value) ? value : null + function visit(value) { + if (!value || typeof value !== 'object' || visited.has(value) || causes.length >= 6) return + visited.add(value) + causes.push({ name: symbol(value.name), code: symbol(value.code), syscall: symbol(value.syscall) }) + visit(value.cause) + if (Array.isArray(value.errors)) for (const nested of value.errors) visit(nested) + } + visit(error) + return { causes } +} + export function planA0LocationIsolation(locations, requiredSlots = 1, maxMutations = A0_MAX_LOCATION_MUTATIONS) { const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1) @@ -291,7 +306,9 @@ function usage() { --password 必填,管理员密码 --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 --pda-password 国内装柜链必填,PDA 作业员密码 + --environment 默认 test;gray 仅允许显式 FBA 定向闭环 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} + --f40-tax-mode F40 税制:BUY_DECLARATION 或 TAX_REFUND --customer-name 指定本轮隔离客户名称,默认自动生成 --merchant-username 复用既有已审核客户账号,不再创建客户 --merchant-password 复用客户账号密码,不写入报告 @@ -317,7 +334,39 @@ function usage() { 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, -MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) +MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT。 +gray 使用独立 ADMIN_GRAY_GATEWAY/USERNAME/PASSWORD、ADMIN_GRAY_PDA_USERNAME/PASSWORD、 +ADMIN_GRAY_CUSTOMER_NAME、MERCHANT_GRAY_USERNAME/PASSWORD;不回落 TEST 凭据。`) +} + +const fleetPoolGrayOptions = new WeakSet() + +/** Dedicated pool orchestrator transport only: no generic module execution or CLI scope switch. */ +export function createFleetPoolGrayOptions(input) { + const keys = ['gateway', 'username', 'password', 'output', 'markerNamespace'] + if (!input || Object.keys(input).some(key => !keys.includes(key)) + || keys.some(key => typeof input[key] !== 'string' || !input[key].trim())) throw Error('Explicit gray pool transport configuration required') + const target = new URL(input.gateway) + if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash || target.pathname !== '/') + throw Error('Gray pool requires the unified HTTPS origin') + const options = Object.freeze({ ...input, environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY', + modules: Object.freeze([]), requestedModules: Object.freeze([]), quiet: true, waitSplitCount: 1, verifyUnloadEventRecovery: false }) + fleetPoolGrayOptions.add(options) + return options +} + +function assertGrayExecutionScope(options) { + if (options.environment !== 'gray') return + if (options.verifyUnloadEventRecovery) throw new Error('gray 禁止 TEST 故障注入') + if (fleetPoolGrayOptions.has(options)) return + if (!Array.isArray(options.requestedModules) || options.requestedModules.length !== 1 + || options.requestedModules[0] !== 'phase5-root-fulfillment' + || !Array.isArray(options.modules) || options.modules.length !== 2 + || options.modules[0] !== 'merchant-qualification-fixture' + || options.modules[1] !== 'phase5-root-fulfillment') { + throw new Error('gray 仅支持显式 phase5-root-fulfillment 及其本轮客户依赖;其他模块尚未核对环境副作用') + } + if (options.waitSplitCount !== 1) throw new Error('gray 定向闭环只支持单轮订单,不运行批量造数') } export function parseArguments(argv) { @@ -337,12 +386,15 @@ export function parseArguments(argv) { } } if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway') - if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported') + const environment = values.get('environment') || 'test' + if (!['test', 'gray'].includes(environment)) throw new Error('Only test or explicit gray acceptance is supported') + if (environment === 'gray' && !values.get('modules')) throw new Error('gray 必须显式指定 --modules phase5-root-fulfillment,不能运行默认全量') + const envValue = (testKey, grayKey) => process.env[environment === 'gray' ? grayKey : testKey] || '' const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) .split(',').map((v) => v.trim()).filter(Boolean) const modules = normalizeModuleOrder(requestedModules) - const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' - const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' + const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME') + const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD') const requiresMarkerOwnedMerchantFixture = [ 'phase5-root-fulfillment', 'phase4-overseas-business', @@ -357,22 +409,22 @@ export function parseArguments(argv) { if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } return { - environment: 'test', - gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), - username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', - password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', - pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '', - pdaPassword: values.get('pda-password') || process.env.ADMIN_PDA_PASSWORD || '', + environment, + gateway: (values.get('gateway') || envValue('ADMIN_TEST_GATEWAY', 'ADMIN_GRAY_GATEWAY')).replace(/\/$/, ''), + username: values.get('username') || envValue('ADMIN_TEST_USERNAME', 'ADMIN_GRAY_USERNAME'), + password: values.get('password') || envValue('ADMIN_TEST_PASSWORD', 'ADMIN_GRAY_PASSWORD'), + pdaUsername: values.get('pda-username') || envValue('ADMIN_PDA_USERNAME', 'ADMIN_GRAY_PDA_USERNAME'), + pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'), modules, requestedModules, - customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', + customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'), merchantUsername, merchantPassword, originWarehouseName: values.get('origin-warehouse-name') || '', channelName: values.get('channel-name') || '', channelCode: values.get('channel-code') || '', orderProfileMode: values.get('order-profile-mode') || 'fixed', - waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT + waitSplitCount: Number(values.get('wait-split-count') || envValue('ADMIN_WAIT_SPLIT_COUNT', 'ADMIN_GRAY_WAIT_SPLIT_COUNT') || (modules.includes('coload-groups') ? 8 : modules.includes('manual-order-splitting-fixture') ? 6 : 1)), waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), @@ -384,6 +436,7 @@ export function parseArguments(argv) { quiet: values.get('quiet') === 'true', returnMainChainOnly: values.get('return-main-chain-only') === 'true', verifyUnloadEventRecovery: values.get('verify-unload-event-recovery') === 'true', + f40TaxMode: values.get('f40-tax-mode') || '', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', @@ -395,6 +448,13 @@ export function parseArguments(argv) { } export function validateExecutionOptions(options) { + assertGrayExecutionScope(options) + if (options.environment === 'gray') { + const target = new URL(options.gateway) + if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash + || target.pathname !== '/') throw new Error('gray gateway 必须为统一 HTTPS 源站,不带 /api、凭据或查询参数') + if (!options.output?.trim()) throw new Error('gray 必须指定独立 --output 报告路径') + } if (options.verifyUnloadEventRecovery && !options.modules.includes('phase4-overseas-pda')) { throw new Error('卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链') } @@ -403,7 +463,8 @@ export function validateExecutionOptions(options) { for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) } - if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported') + if (options.environment && !['test', 'gray'].includes(options.environment)) throw new Error('Only test or explicit gray acceptance is supported') + if (options.f40TaxMode && !['BUY_DECLARATION', 'TAX_REFUND'].includes(options.f40TaxMode)) throw new Error('f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND') if ('overseasGateway' in options) throw new Error('Use the single gateway option') if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module)) && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { @@ -551,9 +612,10 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = export class AcceptanceRun { constructor(options) { + assertGrayExecutionScope(options) this.options = options const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) - this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` + this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.startedAt = new Date().toISOString() this.steps = [] this.modules = [] @@ -562,7 +624,7 @@ export class AcceptanceRun { this.phase4ProducerAttempts = new Map() this.deploymentBlockers = [] this.coverageGaps = [] - this.context = {} + this.context = options.f40TaxMode ? { f40TaxMode: options.f40TaxMode } : {} this.token = '' this.pdaToken = '' this.refreshToken = '' @@ -596,7 +658,7 @@ export class AcceptanceRun { await new Promise((resolve) => setTimeout(resolve, attempt * 250)) continue } - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, transportError: transportFailureDetails(error) } } } throw new Error('unreachable request attempt state') @@ -628,7 +690,7 @@ export class AcceptanceRun { requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } } catch (error) { - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, transportError: transportFailureDetails(error) } } } @@ -671,6 +733,7 @@ export class AcceptanceRun { code: response?.body?.code === undefined ? null : String(response.body.code), message: response?.body?.message || response?.raw || null, requestId: response?.requestId || null, traceId: response?.traceId || null, + ...(response?.transportError ? { transportError: response.transportError } : {}), evidence, checkedAt: new Date().toISOString(), } this.steps.push(step) @@ -1869,8 +1932,8 @@ export class AcceptanceRun { const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { - reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, - gateway: this.options.gateway, gateway: this.options.gateway, + reportName: this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker, + environment: this.options.environment || 'test', gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, summary, @@ -1878,8 +1941,9 @@ export class AcceptanceRun { coverageGaps: this.coverageGaps, createdResources: this.createdResources, excluded: [ - { scope: 'dashboard', reason: '首页为静态演示数据,不调用后端 API' }, { scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' }, + ...(this.options.environment === 'gray' ? [{ scope: 'full-regression-and-fault-injection', + reason: '仅执行显式 FBA 闭环及所需正式业务依赖;不覆盖默认全量、TEST 故障注入、真实邮件或供应商自动查询' }] : []), ], securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。', steps: this.steps, @@ -1898,12 +1962,16 @@ export function unloadRecoveryWasVerified(run) { } export async function configureReadOnlyEvidence(run, environment, createReaders, faultFactories) { + assertGrayExecutionScope(run.options) if (!run.options.modules.some(name => ['phase4-overseas-pda', 'phase5-root-fulfillment', 'manual-state-matrix'].includes(name))) return - const factory = createReaders || (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders + const factory = createReaders || (run.options.environment === 'gray' + ? (await import('./gray-regression-evidence.mjs')).createGrayEvidenceReaders + : (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders) const readers = factory(environment) await readers.preflight() run.context.phase4ReceivingEvidenceReader = readers.readReceiving run.context.f40ReadOnlyOutboxEvidence = readers.readCompletion + if (run.options.environment === 'gray') return const faults = [] if (run.options.modules.includes('phase4-cross-region-events')) { const { createTestFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs') diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 8e501359f..84db159ec 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -70,7 +70,7 @@ 对应本地数据库事务测试为 Firstleg `CarrierScheduleExceptionPersistenceTest`,使用真实 Service/Repository/MyBatis 和 H2,验证四类异常及解除的同事件重放、异摘要冲突、正常实际/预计节点整行不变,以及异常历史写入失败时事务回滚。它属于本地持久化验证,不代替 TEST MySQL 或真实供应商调度链验收。 -`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 +`phase5-root-fulfillment.mjs` 是 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;TEST 与显式 `--environment gray` 的环境参数、授权和排除范围见脚本根 README。模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。海外 PDA 必须按本轮客户、柜次、来源入仓 ID 和入仓码确定实际唯一接柜仓,再核对 Warehouse 主档;不得按仓列表顺序选择另一仓。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 默认税制为 `BUY_DECLARATION`;编程调用显式设置 `AcceptanceRun.context.f40TaxMode='TAX_REFUND'` 可执行退税链。内部模块 `phase5-root-tax-customs.mjs` 在每个实装柜走客户上传、平台回执、客户确认、发送执行、人工正式申报与放行、完成文件,再进入尾程;订单最终完成后回查本客户资料并校验原件、回执、完成文件的实际下载字节和 SHA-256。该模块不能独立执行,不用 SQL 修改状态,不借历史资料;依赖相同 TEST 授权、当轮客户与只读取证器,失败及重跑保留各轮原始报告。直接合同为 `phase5-root-tax-customs-contract.test.mjs`,离线通过不代替实际 HTTP 或页面验收。 @@ -78,7 +78,7 @@ 会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。 -配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。 +配置仅来自显式指定的目标环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。 单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 @@ -88,17 +88,17 @@ `pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。 -退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 +退货专项核验退货单、退货主单、签收单、批次和合箱生成箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;托盘签收生成的实物箱唛采用当前 `RC` 前缀及箱数/序号标签协议,必须从签收响应 `cartonMark` 读取,不能用 `palletCode` 替代。不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。 -退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。最后一批先验证缺照片拒绝及库存不变,再上传明确为合成测试图片的 PNG 并完成签收;不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。 +退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。 `return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。 扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。 -边界扩展创建的每张退货单也必须先审核,并由绑定该仓的 PDA 经正式接口生成本轮退货实收托盘、上传合成打托测试图片;客户身份通过本轮登录会话映射,禁止用主客户替代第二客户。签收请求传 `palletCode`,成功响应核对同一托盘而非已移除的 `cartonMark` 字段;流水分两批时分别使用两个正式托盘。混合明细场景先验证两种 SKU 同批拒绝且不落账,再分别签收为两个来源库存箱以继续合箱回滚验证。三轮双请求并发范围保持不变。 +边界扩展创建的每张退货单也必须先审核,再由绑定仓 PDA 按 `PARCEL` 实收、上传可选合成照片;客户身份由本轮登录会话映射。散件签收使用独立包裹 `unitCode`,库存定位读取响应 `cartonMark`,所有箱码路径必须编码。合法多 SKU 同包裹生成独立散件库存,并经散件合箱接口核对并发、回滚和流水;主场景的 `PALLET` 实物箱通过 `merge-boxes` 和逐箱 `sourceDeductions` 核对合并、重放及数量守恒。三轮双请求并发范围保持不变。 同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。 @@ -263,12 +263,13 @@ export async function run(context) { | 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed | | Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | +| 首页实时指标 | `dashboard.mjs` | `dashboard-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` | | Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | | 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 | | 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | -| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | +| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) | | 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 | | 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | @@ -288,7 +289,7 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 -尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 +尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、下载中心 XLSX 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes @@ -426,3 +427,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra 国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。 `event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。 + +下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。 diff --git a/scripts/admin-test-env-modules/dashboard-contract.test.mjs b/scripts/admin-test-env-modules/dashboard-contract.test.mjs new file mode 100644 index 000000000..4683387e4 --- /dev/null +++ b/scripts/admin-test-env-modules/dashboard-contract.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { run, validOperationalMetrics } from './dashboard.mjs' +import { readFileSync } from 'node:fs' + +const metrics = () => ({ inTransitContainerCount: '3', inTransitTeu: '4.5', unknownContainerTypeCount: '1', pendingCustomsOrderCount: '2', asOf: '2026-10-06T01:00:00' }) +test('实时指标拒绝缺失、负数、非整数计数及未知箱型超出总柜数', () => { + assert.equal(validOperationalMetrics(metrics()), true) + for (const field of ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount', 'inTransitTeu', 'asOf']) { + for (const value of [null, undefined, '', -1, 'invalid']) assert.equal(validOperationalMetrics({ ...metrics(), [field]: value }), false) + } + assert.equal(validOperationalMetrics({ ...metrics(), pendingCustomsOrderCount: 1.5 }), false) + assert.equal(validOperationalMetrics({ ...metrics(), unknownContainerTypeCount: 4 }), false) +}) +const fake = () => { + const calls = [] + return { calls, createdResources: {}, assert(_m, _n, _r, passed) { assert.equal(passed, true) }, + apiSuccess(_m, _n, response) { return response.body.data }, + async request(method, path) { calls.push({ method, path }); return { http: 200, body: { code: '200', data: path.includes('/operational-metrics') ? metrics() : { total: '9', records: [{}] } } } } } +} +test('默认编排实时首页三次只读查询,今日范围和启用客户筛选与页面一致', async () => { + const context = fake(); await run(context) + assert.equal(context.createdResources.dashboard.status, 'VERIFIED') + assert.equal(context.calls.length, 3) + assert.ok(context.calls.every(row => row.method === 'GET')) + const query = new URL(context.calls[1].path, 'https://offline.invalid').searchParams + assert.equal(query.get('createdFrom'), context.createdResources.dashboard.businessDate + 'T00:00:00.000') + assert.equal(query.get('createdTo'), context.createdResources.dashboard.businessDate + 'T23:59:59.999') + assert.equal(query.get('size'), '1') + assert.ok(context.calls[2].path.endsWith('status=ACTIVE')) + const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'dashboard'/) + assert.doesNotMatch(entry, /首页为静态演示数据/) +}) +test('非成功响应和缺失服务端总数不得被记为看板通过', async () => { + for (const response of [{ http: 200, body: { code: 'S0500', data: metrics() } }, { http: 200, body: { code: '200', data: { records: [] } } }]) { + const context = fake(); context.request = async () => response + await assert.rejects(run(context)); assert.equal(context.createdResources.dashboard.status, 'CHECKING') + } +}) diff --git a/scripts/admin-test-env-modules/dashboard.mjs b/scripts/admin-test-env-modules/dashboard.mjs new file mode 100644 index 000000000..b0c1858a3 --- /dev/null +++ b/scripts/admin-test-env-modules/dashboard.mjs @@ -0,0 +1,44 @@ +const MODULE = 'dashboard' +const OPTIONS = { scenario: 'platform-live-dashboard', deploymentBlocker: true } +const integer = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0 +const decimal = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value))) + && Number.isFinite(Number(value)) && Number(value) >= 0 + +export function validOperationalMetrics(value) { + return Boolean(value && ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount'] + .every(key => integer(value[key])) && decimal(value.inTransitTeu) + && Number(value.unknownContainerTypeCount) <= Number(value.inTransitContainerCount) + && typeof value.asOf === 'string' && Number.isFinite(Date.parse(value.asOf))) +} + +export async function run(context) { + const proof = context.createdResources.dashboard = { status: 'CHECKING', readOnly: true } + const check = (label, response, condition) => { + context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw Error(label) + } + const read = async (path, label) => { + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + check(`${label}正式HTTP成功`, response, response.http === 200 && String(response.body?.code) === '200' && data != null) + return { response, data } + } + const metrics = await read('/api/platform/v1/first-leg/dashboard/operational-metrics', '读取首页实时运营指标') + check('在途柜量与待清关指标完整且非负,不以缺失值冒充零', metrics.response, validOperationalMetrics(metrics.data)) + proof.operationalMetrics = metrics.data + // The deployed TEST services and the desktop page use the Shanghai business day. + const date = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + const query = new URLSearchParams({ createdFrom: `${date}T00:00:00.000`, createdTo: `${date}T23:59:59.999`, page: '1', size: '1' }) + for (const [key, path, label] of [ + ['todayNewOrderCount', `/api/platform/v1/orders?${query}`, '读取今日新运单总数'], + ['activeCustomerCount', '/api/platform/v1/iam/tenants?page=1&size=1&status=ACTIVE', '读取启用客户总数'], + ]) { + const { response, data } = await read(path, label) + check(`${label}使用服务端分页总数`, response, integer(data.total) && Array.isArray(data.records) + && data.records.length <= 1 && Number(data.total) >= data.records.length) + proof[key] = data.total + } + proof.businessDate = date + proof.status = 'VERIFIED' +} diff --git a/scripts/admin-test-env-modules/download-center-contract.test.mjs b/scripts/admin-test-env-modules/download-center-contract.test.mjs new file mode 100644 index 000000000..514cbf0c2 --- /dev/null +++ b/scripts/admin-test-env-modules/download-center-contract.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { verifyDownloadCenterExport } from './download-center.mjs' +import { exportWorkbook } from './download-center-fixture.mjs' + +function fixture(status = 'SUCCEEDED', values = ['LM-1']) { + const calls = [] + return { + marker: 'export-test', calls, + async request(method, path, body) { + calls.push({ method, path, body }) + return { http: 200, body: { code: '200', data: { id: 'EXP-1', status: method === 'POST' ? 'PENDING' : status } } } + }, + apiSuccess(_module, _name, response) { return response.body.data }, + assert(_module, name, _response, passed) { assert.ok(passed, name) }, + async requestBinary(method, path) { + calls.push({ method, path }) + return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: exportWorkbook(['尾程单号'], values) } + }, + } +} +test('creates, polls and validates the actual XLSX through File', async () => { + const context = fixture() + await verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', { orderNo: 'LM-1' }, ['尾程单号'], ['LM-1']) + assert.deepEqual(context.calls[0].body.parameters, { orderNo: 'LM-1' }) + assert.equal(context.calls[0].body.format, 'XLSX') + assert.ok(context.calls.at(-1).path.endsWith('/EXP-1/download')) +}) +test('failed generation cannot be reported as a successful download', async () => { + const context = fixture('FAILED') + await assert.rejects(verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号'])) + assert.equal(context.calls.some(call => call.path.endsWith('/download')), false) +}) +test('a file missing this run business data fails acceptance', async () => { + await assert.rejects(verifyDownloadCenterExport(fixture('SUCCEEDED', ['OTHER']), 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号'], ['LM-1'])) +}) diff --git a/scripts/admin-test-env-modules/download-center-fixture.mjs b/scripts/admin-test-env-modules/download-center-fixture.mjs new file mode 100644 index 000000000..cdc05cc79 --- /dev/null +++ b/scripts/admin-test-env-modules/download-center-fixture.mjs @@ -0,0 +1,18 @@ +// Offline XLSX fixture used only by download-center contract tests. +export function exportWorkbook(headers, values = []) { + const escape = value => String(value ?? '').replace(/&/g, '&').replace(//g, '>') + const rows = [headers, values].map((row, index) => `${row.map((value, col) => { + const letter = String.fromCharCode(65 + col) + return `${escape(value)}` + }).join('')}`).join('') + const name = Buffer.from('xl/worksheets/sheet1.xml') + const body = Buffer.from(`${rows}`) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(body.length, 22); local.writeUInt16LE(name.length, 26) + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10) + end.writeUInt32LE(central.length + name.length, 12); end.writeUInt32LE(local.length + name.length + body.length, 16) + return Buffer.concat([local, name, body, central, name, end]) +} diff --git a/scripts/admin-test-env-modules/download-center.mjs b/scripts/admin-test-env-modules/download-center.mjs new file mode 100644 index 000000000..310c7f734 --- /dev/null +++ b/scripts/admin-test-env-modules/download-center.mjs @@ -0,0 +1,31 @@ +import { parseXlsx } from './finance-downloads.mjs' + +// Internal acceptance helper; callers retain their own fixture and deployment authorization. +export async function verifyDownloadCenterExport(context, module, exportType, parameters, headers, tokens = [], expectedStatus = 'SUCCEEDED') { + const options = { scenario: 'download-center-export', deploymentBlocker: true } + const base = '/api/platform/v1/files/export-tasks' + const created = await context.request('POST', base, { exportType, format: 'XLSX', taskName: `${context.marker}-${exportType}`, parameters }) + const task = context.apiSuccess(module, `${exportType}创建下载任务`, created, '', options) + if (!task?.id) throw new Error('下载任务创建失败') + let state = task + for (let attempt = 0; attempt < 45 && !['SUCCEEDED', 'FAILED', 'EXPIRED'].includes(state.status); attempt++) { + if (attempt) await new Promise(resolve => setTimeout(resolve, 2000)) + const response = await context.request('GET', `${base}/${encodeURIComponent(String(task.id))}`) + state = context.apiSuccess(module, `${exportType}查询下载任务`, response, '', options) + if (!state) throw new Error('下载任务查询失败') + } + context.assert(module, `${exportType}后台生成完成`, created, state.status === expectedStatus, state.failureMessage || state.status, options) + if (expectedStatus === 'FAILED' && state.status === 'FAILED') { + context.assert(module, `${exportType}失败原因可见`, created, Boolean(state.failureMessage), state.failureMessage || '', options) + return created + } + if (state.status !== 'SUCCEEDED') throw new Error(`导出未完成:${state.status}`) + const response = await context.requestBinary('GET', `${base}/${encodeURIComponent(String(task.id))}/download`) + context.assert(module, `${exportType}下载XLSX`, response, response.http === 200 && String(response.contentType).includes('spreadsheetml'), `bytes=${response.bytes?.length || 0}`, options) + const workbook = parseXlsx(response.bytes) + const content = workbook.rows.flat().join('|') + const valid = headers.every(header => workbook.headers.includes(header)) && tokens.every(token => content.includes(String(token))) + context.assert(module, `${exportType}表头与marker内容`, response, valid, `headers=${workbook.headers.join('|')}`, options) + if (!valid) throw new Error('导出文件缺少预期字段或本轮业务数据') + return response +} diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs index 126ce07ce..dea04930d 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -1,6 +1,8 @@ +import { exportWorkbook } from './download-center-fixture.mjs' +import { parseXlsx } from './finance-downloads.mjs' import assert from 'node:assert/strict' import test from 'node:test' -import { run, runQuoteSession, verifyOperations, requireCurrentFixture, readCsv, fixtureContract } from './fleet-quotes-operations.mjs' +import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureContract } from './fleet-quotes-operations.mjs' import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' const copy = value => structuredClone(value) @@ -10,6 +12,7 @@ function harness(fault) { let next = 0, supplierStatus = 'ACTIVE' let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } } const measuredHistory = [] + const exportTasks = new Map() const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] } const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' }) function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) } @@ -31,6 +34,16 @@ function harness(fault) { apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data }, async request(method, path, body) { calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname + if (p === '/api/platform/v1/files/export-tasks' && method === 'POST') { + const taskId = String(exportTasks.size + 1) + exportTasks.set(taskId, body) + return response({ id: taskId, status: 'PENDING' }) + } + if (p.startsWith('/api/platform/v1/files/export-tasks/')) { + const task = exportTasks.get(p.split('/').at(-1)) + const failed = task.parameters.kind === 'INVALID' || (task.parameters.from && Date.parse(task.parameters.from) > Date.parse(task.parameters.to)) + return response({ id: p.split('/').at(-1), status: failed ? 'FAILED' : 'SUCCEEDED', failureMessage: failed ? '筛选参数无效' : null }) + } if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } }) if (p === '/api/platform/v1/suppliers' && method === 'POST') { for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field) @@ -90,11 +103,11 @@ function harness(fault) { }, async requestBinary(method, path) { calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid') - const error = response(null, 'A0400') - if (u.pathname.includes('/quotes/') && u.searchParams.get('size') === '0') { const invalid = response(null, 'A0422'); return { ...invalid, contentType: 'application/json', bytes: Buffer.from(JSON.stringify(invalid.body)) } } - if (fault === 'json export masquerades as csv' || (u.searchParams.has('from') && Date.parse(u.searchParams.get('from')) > Date.parse(u.searchParams.get('to')))) return { ...error, contentType: fault ? 'text/csv' : 'application/json', bytes: Buffer.from(JSON.stringify(error.body)) } - const text = u.pathname.includes('/quotes/') ? `\uFEFF报价号,金额\r\n${[...rows.keys()].join('\r\n')}` : `\uFEFF车次号,实际里程(mile)\r\n${tripNo},25.5` - return { http: 200, body: null, bytes: Buffer.from(text), contentType: 'text/csv;charset=UTF-8', requestId: 'offline-request', traceId: 'offline-trace' } + assert.match(u.pathname, /files\/export-tasks\/[^/]+\/download$/) + if (fault === 'json export masquerades as csv') return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: Buffer.from('{"code":"A0400"}') } + const task = exportTasks.get(u.pathname.split('/').at(-2)) + const bytes = task.exportType === 'FLEET_QUOTE_LIST' ? exportWorkbook(['报价号'], [[...rows.keys()].join(' / ')]) : exportWorkbook(['车次号', '实际里程(mile)'], [tripNo, '25.5']) + return { http: 200, body: null, bytes, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'offline-request', traceId: 'offline-trace' } }, } return { context, rows, calls, supplierStatus: () => supplierStatus } @@ -127,7 +140,7 @@ test('missing or stale fixture fails before any HTTP and cannot be treated as sk test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => { const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof) - assert.throws(() => readCsv({ http: 200, contentType: 'text/csv', bytes: Buffer.from('{"code":"A0403"}') }, '报价号'), /not a valid CSV/) + assert.throws(() => parseXlsx(Buffer.from('{"code":"A0403"}')), /ZIP/) assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/) }) test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => { @@ -141,10 +154,10 @@ test('quote acceptance is independent of DEPART and never requests operations, i assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED') assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path))) }) -test('operations defaults to read-only and never creates customer, supplier or quote records', async () => { +test('operations only reads business data and creates download tasks, never customer, supplier or quote records', async () => { const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture await verifyOperations(context) - assert.ok(calls.every(call => call.method === 'GET')); assert.equal(rows.size, 0) + assert.ok(calls.every(call => call.method === 'GET' || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks' && call.body.exportType === 'TRIP_OPERATIONS_LIST'))); assert.equal(rows.size, 0) assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED') }) test('measured writes require an explicit independent idempotency namespace', async () => { diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 1e8d132e8..683d9bff2 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -1,3 +1,4 @@ +import { verifyDownloadCenterExport } from './download-center.mjs' import { createHash } from 'node:crypto' import { expectedAllocations } from './fleet-trip-cost-acceptance.mjs' @@ -116,14 +117,6 @@ function requireQuoteCustomer(context) { return customer } -export function readCsv(response, requiredHeader) { - const text = new TextDecoder().decode(response?.bytes || new Uint8Array()).replace(/^\uFEFF/, '') - if (response?.http !== 200 || /json/i.test(response?.contentType || '') || /^\s*[{[]/.test(text) - || !/^text\/csv(?:;|$)/i.test(response?.contentType || '') || !text.split(/\r?\n/, 1)[0].includes(requiredHeader)) { - throw new Error(`Export is not a valid CSV: HTTP=${response?.http}, code=${response?.body?.code || 'n/a'}, requestId=${response?.requestId || 'n/a'}, traceId=${response?.traceId || 'n/a'}`) - } - return text -} const currency = value => { if (!['CNY', 'USD'].includes(value)) throw new Error('Unsupported or missing currency fact'); return value } const amounts = rows => rows.map(row => [currency(row.currency), number(row.amount)]).sort((a, b) => a[0].localeCompare(b[0])) const costs = rows => rows.map(row => [currency(row.currency), number(row.directCost), row.ownCost == null ? null : number(row.ownCost), row.externalCost == null ? null : number(row.externalCost)]).sort((a, b) => a[0].localeCompare(b[0])) @@ -197,6 +190,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames check(label, response, response.http === 200 && id(response.body?.code) === expectedCode && response.requestId && response.traceId) } async function binary(path) { const response = await context.requestBinary('GET', path); recordResponse('GET', path, response); return response } + const exportContext = Object.assign(Object.create(context), { request, requestBinary: (_method, path) => binary(path) }) function retain(row) { check('报价返回稳定业务号', null, typeof row?.quoteNo === 'string' && row.quoteNo.length > 0) if (!proof.quotes.includes(row.quoteNo)) proof.quotes.push(row.quoteNo) @@ -281,13 +275,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames check('用途独立状态未串改', null, stillPurchase.status === 'ACTIVE' && id(stillPurchase.recordVersion) === id(purchase.recordVersion)) const history = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}/history`, undefined, '读取报价不可变版本历史') check('历史保留原始零价与更正版本', null, history.length === 3 && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 12.5) && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 15)) - const quoteCsv = await binary(`${QUOTES}/export?${query({ keyword: details.originCity })}`) - const quoteText = readCsv(quoteCsv, '报价号') - check('报价CSV为真实文件并包含本轮业务号', quoteCsv, quoteText.includes(sale.quoteNo) && quoteText.includes(purchase.quoteNo)) - const error = await binary(`${QUOTES}/export?size=0`) - check('报价导出参数错误保留业务码', error, error.http === 200 && id(error.body?.code) === 'A0422') - let rejected = false; try { readCsv(error, '报价号') } catch { rejected = true } - check('报价下载拒绝JSON错误文件', error, rejected) + await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { keyword: details.originCity }, ['报价号'], [sale.quoteNo, purchase.quoteNo]) + await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { kind: 'INVALID' }, [], [], 'FAILED') proof.quoteStatus = 'VERIFIED' } if (scope === 'OPERATIONS_ONLY') { @@ -308,14 +297,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames } const finalReport = await report() if (writeMeasured) check('运营列表批量回显最新实测记录', null, Number(finalReport.page.records[0].measured?.actualDistanceMiles) === 25.5 && Number(finalReport.page.records[0].measured?.actualFuelLitres) === 8.2) - const exported = await binary(`${OPERATIONS}/export?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo })}`) - const operationsCsv = readCsv(exported, '实际里程(mile)') - check('运营CSV包含本轮车次及实际里程字段', exported, operationsCsv.includes(fixture.tripNo) && (!writeMeasured || operationsCsv.includes('25.5'))) - const exportError = await binary(`${OPERATIONS}/export?${query({ from: to, to: from })}`) - check('错误导出保留标准业务错误而非CSV成功', exportError, exportError.http === 200 && id(exportError.body?.code) === 'A0400') - let rejected = false - try { readCsv(exportError, '车次号') } catch { rejected = true } - check('下载检查器拒绝JSON错误响应', exportError, rejected) + await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo }, ['车次号', '实际里程(mile)'], [fixture.tripNo, ...(writeMeasured ? ['25.5'] : [])]) + await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from: to, to: from }, [], [], 'FAILED') proof.operationsStatus = 'VERIFIED' } proof.status = 'VERIFIED' diff --git a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs new file mode 100644 index 000000000..18334ea93 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { moneyCents, validateTripFinance, verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs' + +const scope = { tripNo: 'TR-CURRENT', dispatchNos: ['DS-CURRENT'], receiptsVisible: true, + businesses: [{ customerId: '9007199254740993', deliveryNo: 'DL-CURRENT' }] } +test('default fulfillment runs finance verification using its own departed trip and delivery numbers', () => { + const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8') + assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/) + assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/) + assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/) +}) +function fixture(status = 'AVAILABLE', received = null) { + return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true, + attributionStatus: 'ATTRIBUTED', attributionReason: '唯一发车来源', finance: { + status: 'AVAILABLE', reason: '有效财务来源', + sources: [{ customerId: scope.businesses[0].customerId, businessNo: 'DL-CURRENT', sourceNo: 'FS-CURRENT', + sourceVersion: 1, status, currency: 'USD', amount: '100.03' }], + totals: [{ currency: 'USD', receivableAmount: '100.03', receivedAmount: received }], + documents: received === null ? [] : [{ billNo: 'BILL-CURRENT', currency: 'USD', receivedAmount: received, + allocationStatus: 'ATTRIBUTED', reason: '已确认收款分配' }], + } } +} +test('decimal amounts use integer cents and refuse unknown or unsafe precision', () => { + assert.equal(moneyCents('100.03'), 10003n) + assert.equal(moneyCents('9007199254740993.00'), 900719925474099300n) + for (const value of [null, undefined, '', -1, NaN, Infinity, '1.005', '1e3', 9007199254740993]) { + assert.throws(() => moneyCents(value)) + } +}) +test('available, billed and settled sources survive partial and full payment readback', () => { + for (const [state, paid] of [['AVAILABLE', null], ['BILLED', '30.01'], ['SETTLED', '100.03']]) { + assert.equal(validateTripFinance(fixture(state, paid), scope), true) + } +}) +test('currencies are reconciled separately without floating point addition', () => { + const value = fixture() + value.finance.sources.push({ ...value.finance.sources[0], sourceNo: 'FS-CNY', currency: 'CNY', amount: '0.20' }) + value.finance.totals.push({ currency: 'CNY', receivableAmount: '0.20', receivedAmount: null }) + assert.equal(validateTripFinance(value, scope), true) + value.finance.totals[0].receivableAmount = '100.23' + assert.throws(() => validateTripFinance(value, scope), /原币/) +}) +test('pending allocation is explicitly unknown and never promoted to complete or zero', () => { + const value = fixture('BILLED') + value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null, + allocationStatus: 'PENDING_ALLOCATION', reason: '账单含本车次以外来源' }] + assert.equal(validateTripFinance(value, scope), true) + value.finance.totals[0].receivedAmount = 0 + assert.throws(() => validateTripFinance(value, scope), /完整实收/) + const empty = fixture() + Object.assign(empty.finance, { sources: [], totals: [], documents: [], status: 'PENDING_ALLOCATION' }) + assert.equal(validateTripFinance(empty, scope), true) + empty.finance.status = 'AVAILABLE' + assert.throws(() => validateTripFinance(empty, scope)) +}) +test('receipt restricted projection cannot leak totals or documents', () => { + const value = fixture('BILLED'); value.receiptsVisible = false + const restricted = { ...scope, receiptsVisible: false } + assert.equal(validateTripFinance(value, restricted), true) + value.finance.totals[0].receivedAmount = '30.01' + assert.throws(() => validateTripFinance(value, restricted), /泄露金额/) + value.finance.totals[0].receivedAmount = null + value.finance.documents = fixture('BILLED', '30.01').finance.documents + assert.throws(() => validateTripFinance(value, restricted), /泄露收款/) +}) + +test('NON_NULL省略未知实收仍保持未知,明确归属的实收和应收不得缺失', () => { + const withoutNulls = value => JSON.parse(JSON.stringify(value, (_key, item) => item === null ? undefined : item)) + assert.equal(validateTripFinance(withoutNulls(fixture()), scope), true) + const restricted = withoutNulls(fixture()); restricted.receiptsVisible = false + assert.equal(validateTripFinance(restricted, { ...scope, receiptsVisible: false }), true) + const pending = fixture('BILLED') + pending.finance.documents = [{ billNo: 'BILL-MIXED', currency: 'USD', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '归属未完成' }] + assert.equal(validateTripFinance(withoutNulls(pending), scope), true) + const missingPaid = fixture('BILLED', '10.00'); delete missingPaid.finance.documents[0].receivedAmount + assert.throws(() => validateTripFinance(missingPaid, scope)) + const missingAmount = fixture(); delete missingAmount.finance.sources[0].amount + assert.throws(() => validateTripFinance(missingAmount, scope)) +}) +test('wrong customer, trip, duplicate sources and invented receipts all reject', () => { + const mutations = [v => { v.tripNo = 'TR-FOREIGN' }, v => { v.dispatchNos = ['DS-FOREIGN'] }, + v => { v.finance.sources[0].customerId = '9007199254740994' }, + v => { v.finance.sources[0].businessNo = 'DL-FOREIGN' }, + v => { v.finance.sources.push({ ...v.finance.sources[0] }) }, + v => { v.finance.sources[0].status = 'VOIDED' }, v => { v.finance.sources[0].sourceVersion = 0 }, + v => { v.finance.totals[0].receivedAmount = 25 }, + v => { v.finance.totals.push({ ...v.finance.totals[0] }) }] + for (const mutate of mutations) { const value = fixture(); mutate(value); assert.throws(() => validateTripFinance(value, scope)) } +}) +test('read-only orchestration retains proof and detects execution state mutation or HTTP failure', async () => { + for (const fault of ['', 'mutation', 'http']) { + const calls = [], checks = []; let reads = 0 + const context = { marker: 'CURRENT', createdResources: {}, + assert: (_m, _n, _r, passed) => checks.push(passed), apiSuccess: (_m, _n, r) => r.body.data, + request: async (method, path) => { + calls.push({ method, path }) + if (path.endsWith('/finance')) return { http: fault === 'http' ? 503 : 200, body: { code: '200', data: fixture() } } + return { http: 200, body: { code: '200', data: { trip: { tripNo: scope.tripNo, + departedAt: '2026-10-05T00:00:00Z', recordVersion: ++reads === 2 && fault === 'mutation' ? 2 : 1 } } } } + } } + if (fault) await assert.rejects(verifyFleetTripFinance(context, 'offline', scope)) + else assert.equal((await verifyFleetTripFinance(context, 'offline', scope)).status, 'READ_ONLY_ATTRIBUTION_VERIFIED') + assert.ok(calls.every(row => row.method === 'GET')) + assert.equal(context.createdResources.fleetTripFinance[0].status, + fault ? 'CHECKING' : 'READ_ONLY_ATTRIBUTION_VERIFIED') + } +}) diff --git a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance.mjs b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance.mjs new file mode 100644 index 000000000..d0ef5d902 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance.mjs @@ -0,0 +1,102 @@ +const BASE = '/api/platform/v1/last-leg/fleet' +const OPTIONS = { scenario: 'fleet-trip-finance-attribution', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const sameSet = (a, b) => a.length === new Set(a).size && b.length === new Set(b).size + && JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()) + +export function moneyCents(value) { + if (!['number', 'string'].includes(typeof value)) throw Error('金额必须是明确数值') + const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(String(value)) + if (!match || (typeof value === 'number' && !Number.isSafeInteger(value * 100) + && Math.abs(value * 100 - Math.round(value * 100)) > 0.000001)) throw Error('金额格式或精度无效') + const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0')) + if (typeof value === 'number' && cents > BigInt(Number.MAX_SAFE_INTEGER)) throw Error('金额超过安全精度') + return cents +} + +// A pending amount stays unknown. This does not use agreed booking prices as paid amounts. +export function validateTripFinance(value, scope) { + const fail = message => { throw Error(`车次财务证据:${message}`) } + if (value?.tripNo !== scope.tripNo || value.receiptsVisible !== scope.receiptsVisible + || !Array.isArray(value.dispatchNos) || !sameSet(value.dispatchNos, scope.dispatchNos)) fail('车次或权限范围漂移') + if (!['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(value.attributionStatus) + || typeof value.attributionReason !== 'string' || !value.attributionReason.trim()) fail('缺少业务归属判定') + const finance = value.finance + if (!['AVAILABLE', 'PENDING_ALLOCATION'].includes(finance?.status) + || !Array.isArray(finance.sources) || !Array.isArray(finance.totals) || !Array.isArray(finance.documents) + || typeof finance.reason !== 'string' || !finance.reason.trim()) fail('财务结果不完整') + const sourceKeys = new Set(), amounts = new Map(), covered = new Set() + for (const source of finance.sources) { + const business = `${id(source.customerId)}:${source.businessNo}` + if (!scope.businesses.some(row => `${row.customerId}:${row.deliveryNo}` === business)) fail('费用混入其他客户或派送') + const key = `${business}:${source.sourceNo}` + if (typeof source.sourceNo !== 'string' || !source.sourceNo.trim() || typeof source.customerId !== 'string' + || !/^[1-9]\d*$/.test(source.customerId) || sourceKeys.has(key) || !Number.isSafeInteger(source.sourceVersion) || source.sourceVersion < 1 + || !['AVAILABLE', 'BILLED', 'SETTLED'].includes(source.status)) fail('费用重复、版本或生命周期无效') + if (!/^[A-Z]{3}$/.test(source.currency)) fail('费用币种无效') + sourceKeys.add(key); covered.add(business) + amounts.set(source.currency, (amounts.get(source.currency) || 0n) + moneyCents(source.amount)) + } + if (finance.status === 'AVAILABLE' && scope.businesses.some(row => !covered.has(`${row.customerId}:${row.deliveryNo}`))) fail('缺失来源却声称应收完整') + if (!finance.sources.length && finance.status !== 'PENDING_ALLOCATION') fail('空来源不能冒充完整金额') + const totals = new Map() + for (const total of finance.totals) { + if (totals.has(total.currency) || !amounts.has(total.currency) + || moneyCents(total.receivableAmount) !== amounts.get(total.currency)) fail('应收未按原币来源核平') + if (!scope.receiptsVisible && total.receivedAmount != null) fail('无实收权限泄露金额') + if (total.receivedAmount != null) moneyCents(total.receivedAmount) + totals.set(total.currency, total) + } + if (totals.size !== amounts.size) fail('币种汇总缺失') + if (!scope.receiptsVisible && finance.documents.length) fail('无实收权限泄露收款来源') + const documents = new Map(), paid = new Map(), pending = new Set() + for (const document of finance.documents) { + if (!document.billNo || documents.has(document.billNo) || !totals.has(document.currency) + || !['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(document.allocationStatus)) fail('收款单据归属或去重无效') + documents.set(document.billNo, document) + if (document.allocationStatus === 'PENDING_ALLOCATION') { + if (document.receivedAmount != null || !document.reason?.trim()) fail('未分配金额必须保留未知值及原因') + pending.add(document.currency) + } else { + paid.set(document.currency, (paid.get(document.currency) || 0n) + moneyCents(document.receivedAmount)) + } + } + for (const [currency, total] of totals) { + if (pending.has(currency) && total.receivedAmount != null) fail('混合未分配来源却给出完整实收') + if (total.receivedAmount != null && (!paid.has(currency) || moneyCents(total.receivedAmount) !== paid.get(currency))) fail('实收与明确归属单据不一致') + } + return true +} + +export async function verifyFleetTripFinance(context, module, scope) { + if (!scope?.tripNo || !scope.dispatchNos?.length || !scope.businesses?.length + || scope.dispatchNos.some(no => !no) || scope.businesses.some(row => !/^[1-9]\d*$/.test(row.customerId) || !row.deliveryNo)) { + throw Error('车次财务检查缺少本轮实际发车及派送来源') + } + const proof = { marker: context.marker, tripNo: scope.tripNo, status: 'CHECKING', receiptsVisible: scope.receiptsVisible, + boundaries: '只读归属和原币核平;无账单/收款样本时不声称已验证部分及全额收款生命周期' } + context.createdResources.fleetTripFinance ??= [] + context.createdResources.fleetTripFinance.push(proof) + const read = async (path, label) => { + const response = await context.request('GET', path) + const value = context.apiSuccess(module, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response.body?.code) !== '200' || !value) throw Error(`${label}失败`) + return { response, value } + } + const path = `${BASE}/trips/${encodeURIComponent(scope.tripNo)}` + const before = await read(path, '财务查询前冻结本轮车次执行状态') + if (before.value.trip?.tripNo !== scope.tripNo || !before.value.trip.departedAt) throw Error('本轮车次尚无实际发车事实') + const result = await read(`${BASE}/operations/${encodeURIComponent(scope.tripNo)}/finance`, '按本轮实际车次查询Finance应收与实收') + proof.result = result.value + try { validateTripFinance(result.value, scope) } + catch (error) { context.assert(module, '车次财务来源、权限及金额严格核平', result.response, false, error.message, OPTIONS); throw error } + context.assert(module, '车次财务来源、权限及金额严格核平', result.response, true, + `sources=${result.value.finance.sources.length}, currencies=${result.value.finance.totals.length}, attribution=${result.value.attributionStatus}`, OPTIONS) + const after = await read(path, '财务查询后回查车次零写入') + const unchanged = JSON.stringify(before.value) === JSON.stringify(after.value) + context.assert(module, '只读财务查询不改变车次、资源与审核', after.response, unchanged, `tripNo=${scope.tripNo}`, OPTIONS) + if (!unchanged) throw Error('只读财务查询改变车次事实') + proof.result = result.value + proof.status = 'READ_ONLY_ATTRIBUTION_VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs new file mode 100644 index 000000000..0745eec95 --- /dev/null +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' + +const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`, + overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n })) +const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] })) +const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId, + dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1, + packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0, + items: [{ ...candidates[n], id: `7${n}` }] } })) +test('registered dispatch flow consumes current-run selected candidates before its main draft', () => { + const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8') + const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)') + assert.ok(call > source.indexOf('const selectedCandidates')) + assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`')) +}) +test('two loading groups preserve distinct dispatches and exact physical sources', () => { + assert.equal(groupedDispatchMatches(results(), groups, candidates), true) + assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true) + for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo }, + r => { r[1].draftContainerId = r[0].draftContainerId }, + r => { r[0].dispatch.items = r[1].dispatch.items }, + r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' }, + r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) { + const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false) + } +}) +function server(fault = '') { + let draft, next = 0, arranged = false + const calls = [], checks = [], orders = new Map() + const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' }) + const context = { marker: 'CURRENT', createdResources: {}, + assert: (_m, label, _r, passed) => checks.push({ label, passed }), + async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }) + if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) } + if (path.endsWith('/drafts/containers')) { + assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity') + const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity } + draft.containers.push(container); return response(container) + } + if (path.endsWith('/draft-locks/lock')) { + draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null) + } + if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : [])) + if (path.endsWith('/drafts/pre-arrange')) { + if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) { + if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = [] + return response(null, 'A0409') + } + if (fault === 'unknown-post') return { http: 0, body: null } + if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true } + const result = groups.map(group => ({ draftContainerId: group.draftContainerId, + dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) })) + if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo + return response(result) + } + if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) } + if (path.includes('/candidates?')) return response(candidates) + const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/) + const order = orders.get(match?.[1]); assert.ok(order, path) + if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) } + return response(structuredClone(order)) + } } + return { context, calls, checks } +} +test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => { + const { context, calls, checks } = server() + const proof = await verifyGroupedDispatch(context, 'offline', candidates) + assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED') + assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED') + assert.equal(proof.results.length, 2) + assert.ok(checks.every(row => row.passed)) + assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3) + assert.equal(calls.filter(row => row.method === 'DELETE').length, 2) + assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path))) +}) +test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => { + const { context, calls } = server('reject-mutates-draft') + await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/) + assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1) + assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED') +}) +test('unknown successful-write transport result is not automatically retried or deleted', async () => { + const { context, calls } = server('unknown-post') + await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/) + assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2) + assert.equal(calls.filter(row => row.method === 'DELETE').length, 0) + assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0) + assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW') +}) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs new file mode 100644 index 000000000..2716a0553 --- /dev/null +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -0,0 +1,124 @@ +const BASE = '/api/platform/v1/last-leg/dispatches' +const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const ok = response => response?.http === 200 && String(response.body?.code) === '200' +const sorted = values => [...values].map(id).sort() + +export function groupedDispatchMatches(result, groups, candidates) { + if (!Array.isArray(result) || result.length !== groups.length) return false + const dispatchNos = new Set(), seen = new Set() + return result.every(row => { + const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId)) + const dispatch = row.dispatch + if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo) + || dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false + seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo) + const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId))) + if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds)) + || dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length + || Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false + return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId) + && id(candidate.warehousePalletId) === id(item.warehousePalletId) + && id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId) + && candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount))) + }) +} + +// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence. +export async function verifyGroupedDispatch(context, module, candidates) { + if (!Array.isArray(candidates) || candidates.length < 2 + || new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length + || new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length + || candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId'] + .some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key])) + || typeof row.inboundCode !== 'string' || !row.inboundCode.trim() + || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) { + throw Error('分组预排缺少本轮两个独立实物托盘') + } + const draftToken = `${context.marker}-GROUPED`.slice(0, 64) + const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' } + context.createdResources.groupedDispatch = proof + const check = (label, response, passed) => { + context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS) + if (!passed) throw Error(`分组预排:${label}`) + } + const request = async (method, path, body) => { + let response + try { response = await context.request(method, path, body) } + catch (error) { + if (!['GET', 'HEAD'].includes(method)) { + proof.outcomeUnknown = true + proof.cleanup = 'PRESERVED_FOR_REVIEW' + } + throw error + } + if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) { + proof.outcomeUnknown = true + proof.cleanup = 'PRESERVED_FOR_REVIEW' + } + return response + } + const command = async (method, path, body, label) => { + const response = await request(method, path, body) + check(label, response, ok(response)); return response.body.data + } + const ownDraft = async () => { + const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域') + if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效') + return rows.filter(row => row.draftToken === draftToken) + } + try { + await command('POST', `${BASE}/drafts/snapshot`, { draftToken, + planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿') + for (const candidate of candidates) { + const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组') + if (!container?.id) throw Error('分组缺少服务端柜ID') + const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] } + proof.groups.push(group) + await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId, + planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组') + } + const before = JSON.stringify(await ownDraft()) + const swapped = proof.groups.map((group, index) => ({ ...group, + planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds })) + const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped }) + check('来源与分组不一致明确拒绝A0409', rejected, + [200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409') + check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before) + const body = { draftToken, groups: proof.groups } + const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车') + if (Array.isArray(result)) proof.results = result + check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates)) + const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放') + check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result)) + proof.status = 'GROUPED_PREARRANGE_CHECKED' + } finally { + // Only release resources whose successful response identified an owned dispatch; never retry an unknown POST. + if (!proof.outcomeUnknown) { + for (const row of proof.results) { + const dispatchNo = row.dispatch?.dispatchNo + if (!dispatchNo) continue + const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货') + check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE' + && groupedDispatchMatches([{ ...row, dispatch: detail }], + proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates) + && detail.items.every(item => /^[1-9]\d*$/.test(id(item.id))) + && Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0) + for (const item of detail.items) { + await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`, + undefined, '移除分组测试明细释放正式托盘锁') + } + const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零') + check('分组对照排车释放后数量归零', null, + empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0) + } + await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿') + const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排') + check('分组验收来源回到正式候选', null, Array.isArray(available) + && candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId)))) + proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED' + } + } + proof.status = 'GROUPED_PREARRANGE_VERIFIED' + return proof +} 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 25afd336f..7719f31ea 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 157]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 159]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -39,7 +39,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|157) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|159) AS initialized_tables;$/) } }) @@ -65,7 +65,7 @@ test('退回实收按真实流水唯一保存,重派拣货必须明确唯一 } const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order') assert.match(pick, /\breceipt_id BIGINT NULL/i) - assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL AND return_receipt_id IS NULL\) OR \(receipt_id IS NULL AND return_receipt_id IS NOT NULL\)\)/i) + assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) = 1\)/i) }) test('车辆报价与实测记录保留业务去重和不可变版本', () => { @@ -121,7 +121,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 417, iam_role_permission: 598, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 419, iam_role_permission: 600, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -145,7 +145,7 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') - assert.ok(admin.has('lastleg:cargo-discard:approve'), 'existing discard review endpoint must be grantable to platform administrator') + assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) } @@ -222,3 +222,26 @@ test('最终业务触发器与循环外键均存在,不留下发布过程', () assert.ok(disable >= 0 && enable > disable) assert.ok(!cn.slice(disable, enable).includes('INSERT INTO')) }) + + +test('退货合箱最终字段直接进入空库建表定义', () => { + for (const [name, fields] of Object.entries({ + ll_return_packing_plan: ['source_items_json', 'result_marks_json', 'result_labels_json', 'request_hash', 'completion_hash'], + ll_return_packing_trace: ['source_carton_mark', 'target_carton_mark', 'return_no', 'sku_code'], + ll_return_inventory_carton_item: ['packing_reserved_quantity'], + ll_return_inventory_operation: ['result_json'], + ll_dispatch_pda_pick: ['return_carton_id'], + ll_dispatch_pda_pick_order: ['return_carton_id', 'return_receipt_id'] + })) { + const definition = table(overseas, 'baiyue_lastleg', name) + for (const field of fields) assert.ok(definition.includes(field), name + '.' + field) + } +}) + +test('退货预占状态符合空库数量和状态约束', () => { + const carton = table(overseas, 'baiyue_lastleg', 'll_return_inventory_carton') + for (const name of ['chk_ll_return_inventory_carton_quantity', 'chk_ll_return_inventory_carton_status']) { + const constraint = carton.split('CONSTRAINT ' + name)[1].split('\n')[0] + for (const status of ['PACKING_RESERVED', 'OUTBOUND_PENDING']) assert.ok(constraint.includes(status), name + ':' + status) + } +}) 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 263728507..b851afeb7 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 @@ -111,6 +111,8 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem const matrix = { pages: { inbounds: { requiredStatuses: ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { WAIT_MEASURE: { id: '1' }, + FEE_CONFIRMING: { id: '5' }, + WAIT_PRICING: { id: '6' }, } }, unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: { WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' }, @@ -121,9 +123,11 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem assert.deepEqual(matrix.pages.inbounds.supportedStatuses, ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) - assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) + assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING']) + assert.equal(matrix.pages.inbounds.coverageGaps.FEE_CONFIRMING, undefined) assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) - assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') + assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING, undefined) + assert.equal(matrix.pages.inbounds.coverageGaps.INBOUNDING.category, 'fixture-unavailable') assert.equal(matrix.pages.unifiedOrders.coverageGaps, undefined) assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:DELIVERED']) assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index a0230ad0e..9d2974454 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -10,9 +10,7 @@ const GROUPS = [ const FIXTURE_UNAVAILABLE = { inbounds: { MEASURE_REJECTED: '当前稳定 Admin 契约未开放测量审核驳回接口,无法稳定构造该状态', - WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING', - FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路', - INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', + INBOUNDING: '当前公开接口没有可独立保留的 INBOUNDING 生成或暂停链路;不通过写业务SQL伪造中间状态', VOIDED: '当前 Controller/Service 没有作废入库单的公开 API', }, } diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index e572094bc..245ca5f22 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -3,7 +3,92 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { createCompletedOrder, transition } from './order-inbound-status-fixtures.mjs' +import { createCompletedOrder, transition, retainFeeConfirmingFixture, retainWaitPricingFixture } from './order-inbound-status-fixtures.mjs' + +test('待计费样本通过同维度真实渠道变更生成且退出独立会话', async () => { + const execute = async (override = {}) => { + const calls = [] + let detailReads = 0 + const order = { id: '9', version: 2, status: 'APPROVED', receivingWarehouseId: '10', + destinationWarehouseId: '11', amazonWarehouseCode: 'ABE4', destinationWarehouseName: 'ABE4', + destinationType: 'FBA', pricingTaxMode: 'TAX_INCLUDED', channelCode: 'OLD', customerRemark: 'current-run' } + const context = { marker: 'current-run', context: { merchantQualificationFixture: { username: 'fixture', password: 'fixture' } }, + async request(method, url, body, token) { + calls.push({ method, url, body, token }) + let data + if (url === '/api/platform/inbounds/8?tenantId=7') { + detailReads += 1 + data = { summary: { id: '8', tenantId: '7', orderId: '9', inboundCode: 'IN-8', + processStatus: detailReads === 1 ? 'FEE_CONFIRMING' : 'WAIT_PRICING', + channelCode: 'NEW', feeStatus: 'UNPRICED', ...(detailReads === 1 ? override.before : override.after) } } + } else if (url.includes('/orders/first-leg/9')) data = { order: { ...order, ...override.order }, cargoes: [{ itemNo: 1 }] } + else if (url === '/api/auth/login') data = { accessToken: 'test-session', refreshToken: 'test-refresh' } + else if (url.includes('/tenant/profile')) data = { id: override.customerId || '7' } + else if (url.includes('/pricing/channels')) data = override.channels ?? [ + { channelCode: 'OLD', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '12' }, + { channelCode: 'NEW', channelName: '真实新渠道', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '13' } ] + else if (url.includes('/pricing/estimate')) data = { channelCode: 'NEW', pricingTaxMode: 'TAX_INCLUDED', priceBookVersionId: '13' } + else if (url.includes('/declaration')) data = { version: 3, channelChanged: true, ...override.changed } + else if (url.includes('processStatus=WAIT_PRICING')) data = { records: override.rows ?? [{ id: '8', processStatus: 'WAIT_PRICING' }] } + return { http: 200, body: { code: '200', data } } + }, apiSuccess: (_m, _n, r) => r.body.data, assert() {} } + try { await retainWaitPricingFixture(context, { inboundId: '8', tenantId: '7', orderId: '9' }) } + finally { + if (calls.some(row => row.url === '/api/auth/login')) assert.equal(calls.at(-1).url, '/api/auth/logout') + } + return calls + } + const calls = await execute() + const mutation = calls.find(row => row.method === 'PUT') + assert.equal(mutation.body.expectedVersion, 2) + assert.equal(mutation.body.channelCode, 'NEW') + assert.equal(calls.filter(row => row.method === 'PUT').length, 1) + assert.equal(calls.find(row => row.url.includes('/pricing/estimate')).body.originWarehouseId, '10') + for (const override of [{ before: { orderId: 'other' } }, { order: { status: 'PROCESSING' } }, + { customerId: 'other' }, { channels: [] }, { changed: { version: 9 } }, + { after: { tenantId: 'other' } }, { rows: [] }]) { + await assert.rejects(execute(override), /WAIT_PRICING/) + } +}) + +test('待费用确认样本必须经真实试算并独立回查费用、详情和筛选', async () => { + const execute = async (override = {}) => { + const calls = [] + let reads = 0 + const fee = { feeId: '90', feeVersion: 1, feeStatus: 'CALCULATED', nextProcessStatus: 'FEE_CONFIRMING' } + const context = { marker: 'current-run', + async request(method, url, body, token, headers) { + calls.push({ method, url, body, headers }) + let data + if (url === '/api/platform/inbounds/8?tenantId=7') { + reads += 1 + data = { summary: { id: '8', tenantId: '7', inboundCode: 'IN-TEST-8', + processStatus: reads === 1 ? 'MEASURE_PENDING' : 'FEE_CONFIRMING', + measureReviewStatus: 'PENDING', ...(reads === 1 ? override.before : override.after) } } + } else if (url.includes('/fees/calculate')) data = { ...fee, ...override.calculated } + else if (url.includes('/fees/latest')) data = { ...fee, ...override.latest } + else data = { records: override.rows ?? [{ id: '8', processStatus: 'FEE_CONFIRMING' }] } + return { http: 200, body: { code: '200', data } } + }, apiSuccess: (_m, _n, response) => response.body.data, + assert() {} } + const result = await retainFeeConfirmingFixture(context, { inboundId: '8', tenantId: '7' }) + return { calls, result } + } + const { calls, result } = await execute() + assert.deepEqual(result, { feeId: '90', feeVersion: 1 }) + assert.equal(calls.length, 5) + const writes = calls.filter(row => row.method !== 'GET') + assert.equal(writes.length, 1) + assert.deepEqual(writes[0].body, { weightSource: 'ACTUAL', volumeSource: 'ACTUAL' }) + assert.ok(writes[0].headers['Idempotency-Key'].startsWith('current-run-')) + assert.match(calls.at(-1).url, /inboundCode=IN-TEST-8&processStatus=FEE_CONFIRMING/) + for (const override of [ + { before: { tenantId: 'other' } }, { before: { processStatus: 'WAIT_SPLIT' } }, + { calculated: { nextProcessStatus: 'WAIT_SPLIT' } }, + { after: { processStatus: 'MEASURE_PENDING' } }, { after: { measureReviewStatus: 'APPROVED' } }, + { latest: { feeId: '91' } }, { latest: { feeStatus: 'SENT' } }, { rows: [] }, + ]) await assert.rejects(execute(override), /FEE_CONFIRMING/) +}) test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{ const calls=[] @@ -91,7 +176,7 @@ test('测量状态截停发生在正确的 HTTP 边界', () => { assert.match(source, /await weightOcr\.run\(context\)/) assert.match(source, /target !== 'MEASURING'/) assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/) - assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) + assert.match(source, /\['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'\]\.includes\(target\) && isSubmit/) assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/) }) @@ -99,8 +184,8 @@ test('入库模块只声明状态全集且不在后续专项生成前预写失 for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) { assert.match(source, new RegExp(`'${status}'`)) } - assert.match(source, /processStatus=WAIT_SPLIT/) - assert.match(source, /查询存量 WAIT_SPLIT 样本/) + assert.match(source, /本轮 WAIT_SPLIT 独立样本缺失/) + assert.doesNotMatch(source, /查询存量 WAIT_SPLIT 样本|legacyInbound|legacyPage/) assert.doesNotMatch(source, /覆盖阻断|fixture-unavailable|recordCoverageGap|addCoverageGap/) }) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 14b04b80f..ca61aa28b 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -1,4 +1,4 @@ -import { findPricedOrderRouteAcrossOrigins, freezeOrderCommand } from './orders.mjs' +import { findPricedOrderRouteAcrossOrigins, findPricedOrderRoute, freezeOrderCommand } from './orders.mjs' const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } @@ -178,19 +178,143 @@ async function waitForInbound(context, fixture, orderId, allowCompletedOrder = f return { response: null, summary: null } } +export async function retainFeeConfirmingFixture(context, { inboundId, tenantId }) { + const suffix = `${query(inboundId)}?tenantId=${query(tenantId)}` + const detailPath = `/api/platform/inbounds/${suffix}` + const beforeResponse = await context.request('GET', detailPath) + const before = context.apiSuccess(MODULE, '试算前核对独立待审核测量样本', beforeResponse, '', OPTIONS) + if (id(before?.summary?.id) !== id(inboundId) + || id(before?.summary?.tenantId) !== id(tenantId) + || before?.summary?.processStatus !== 'MEASURE_PENDING' || !before?.summary?.inboundCode) { + throw new Error('FEE_CONFIRMING 必须从本轮独立 MEASURE_PENDING 入库单生成') + } + const calculatedResponse = await context.request('POST', + `/api/platform/v1/inbounds/${query(inboundId)}/fees/calculate?tenantId=${query(tenantId)}`, + { weightSource: 'ACTUAL', volumeSource: 'ACTUAL' }, undefined, + { 'Idempotency-Key': key(context, `retain-fee-confirming-${inboundId}`) }) + const calculated = context.apiSuccess(MODULE, '审核前真实试算并保留待费用确认样本', calculatedResponse, '', OPTIONS) + if (!calculated?.feeId || calculated?.feeStatus !== 'CALCULATED' + || calculated?.nextProcessStatus !== 'FEE_CONFIRMING') { + throw new Error('真实费用试算未生成 FEE_CONFIRMING/CALCULATED') + } + const latestResponse = await context.request('GET', + `/api/platform/v1/inbounds/${query(inboundId)}/fees/latest?tenantId=${query(tenantId)}`) + const latest = context.apiSuccess(MODULE, '回读待确认费用的同一快照', latestResponse, '', OPTIONS) + const detailResponse = await context.request('GET', detailPath) + const detail = context.apiSuccess(MODULE, '回读待费用确认样本持久状态', detailResponse, '', OPTIONS) + const valid = id(detail?.summary?.id) === id(inboundId) + && id(detail?.summary?.tenantId) === id(tenantId) + && detail?.summary?.processStatus === 'FEE_CONFIRMING' + && detail?.summary?.measureReviewStatus === 'PENDING' + && id(latest?.feeId) === id(calculated.feeId) + && latest?.feeVersion === calculated.feeVersion && latest?.feeStatus === 'CALCULATED' + context.assert(MODULE, '费用已持久化且测量尚未审核,保留 FEE_CONFIRMING', detailResponse, valid, + `status=${detail?.summary?.processStatus || 'missing'}, feeId=${id(latest?.feeId)}`, OPTIONS) + if (!valid) throw new Error('FEE_CONFIRMING 持久状态或费用快照不一致') + const listResponse = await context.request('GET', + `/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=FEE_CONFIRMING&page=1&size=20`) + const page = context.apiSuccess(MODULE, '按 FEE_CONFIRMING 查询保留样本', listResponse, '', OPTIONS) + const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'FEE_CONFIRMING') + context.assert(MODULE, '待费用确认筛选包含本轮真实样本', listResponse, visible, + `inboundId=${inboundId}`, OPTIONS) + if (!visible) throw new Error('FEE_CONFIRMING 列表未包含本轮样本') + return { feeId: id(latest.feeId), feeVersion: latest.feeVersion } +} + +export async function retainWaitPricingFixture(context, { inboundId, tenantId, orderId }) { + const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` + const before = context.apiSuccess(MODULE, '渠道变更前核对独立费用确认样本', + await context.request('GET', detailPath), '', OPTIONS) + const orderDetail = context.apiSuccess(MODULE, '渠道变更前读取当前订单版本与申报', + await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(tenantId)}`), '', OPTIONS) + const order = orderDetail?.order + if (id(before?.summary?.id) !== id(inboundId) || id(before?.summary?.tenantId) !== id(tenantId) + || id(before?.summary?.orderId) !== id(orderId) || before?.summary?.processStatus !== 'FEE_CONFIRMING' + || id(order?.id) !== id(orderId) || order?.status !== 'APPROVED' || !orderDetail?.cargoes?.length) { + throw new Error('WAIT_PRICING 必须从本轮 APPROVED 订单及 FEE_CONFIRMING 入库单生成') + } + const fixture = context.context?.merchantQualificationFixture + const username = fixture?.username || context.options?.merchantUsername || process.env.MERCHANT_TEST_USERNAME + const password = fixture?.password || context.options?.merchantPassword || process.env.MERCHANT_TEST_PASSWORD + const login = context.apiSuccess(MODULE, '渠道样本客户独立登录', + await context.request('POST', '/api/auth/login', { loginId: username, password }, ''), '', OPTIONS) + if (!login?.accessToken) throw new Error('WAIT_PRICING 客户登录缺少会话') + const token = login.accessToken + try { + const profile = context.apiSuccess(MODULE, '核对渠道目录所属客户', + await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, token), '', OPTIONS) + if (id(profile?.id) !== id(tenantId)) throw new Error('WAIT_PRICING 渠道目录客户身份不一致') + const channels = context.apiSuccess(MODULE, '查询同仓可替换报价渠道', + await context.request('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(order.receivingWarehouseId)}`, + undefined, token), '', OPTIONS) + const route = await findPricedOrderRoute({ + receiving: { id: order.receivingWarehouseId }, + destinations: [{ id: order.destinationWarehouseId, warehouseCode: order.amazonWarehouseCode, + warehouseName: order.destinationWarehouseName }], + channels: (Array.isArray(channels) ? channels : []).filter(channel => channel.channelCode !== order.channelCode + && channel.pricingTaxModes?.includes(order.pricingTaxMode)) + .map(channel => ({ ...channel, pricingTaxModes: [order.pricingTaxMode] })), + destinationType: order.destinationType, concurrency: 1, + estimate: body => context.request('POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token), + }) + if (!route?.priced?.channel) throw new Error('WAIT_PRICING 没有同仓同目的仓同税制的实际可计价替换渠道') + const channel = route.priced.channel + const changedResponse = await context.request('PUT', + `/api/platform/v1/orders/${query(orderId)}/declaration?tenantId=${query(tenantId)}`, + { expectedVersion: Number(order.version), customerRemark: order.customerRemark, + expectedArrivalAt: order.expectedArrivalAt || null, estimatedVolumeCbm: order.estimatedVolumeCbm ?? null, + channelCode: channel.channelCode, channelName: channel.channelName, + cargoes: orderDetail.cargoes.map(cargo => Object.fromEntries([ + 'itemNo', 'cargoName', 'cargoNameEn', 'skuNo', 'model', 'packageCount', 'totalQuantity', + 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg', + 'hsCode', 'customsHsCode', 'unitsPerPackage', 'unitDeclaredValue', 'declaredValue', + 'currency', 'material', 'usage', 'brand', 'cargoRemark', + ].map(field => [field, cargo[field] ?? (field === 'cargoRemark' ? cargo.remark : null) ?? null]))) }) + const changed = context.apiSuccess(MODULE, '通过正式申报接口变更本轮未发布费用订单渠道', changedResponse, '', OPTIONS) + if (Number(changed?.version) !== Number(order.version) + 1 || changed?.channelChanged !== true) { + throw new Error('WAIT_PRICING 渠道变更未确认当前订单版本') + } + let detail + let response + for (let attempt = 0; attempt < 30; attempt += 1) { + response = await context.request('GET', detailPath) + detail = context.apiSuccess(MODULE, '等待渠道变更及费用失效事实收敛', response, '', OPTIONS) + if (detail?.summary?.processStatus === 'WAIT_PRICING' + && detail?.summary?.channelCode === channel.channelCode) break + if (attempt < 29) await wait(500) + } + const valid = id(detail?.summary?.id) === id(inboundId) && id(detail?.summary?.tenantId) === id(tenantId) + && detail?.summary?.processStatus === 'WAIT_PRICING' && detail?.summary?.feeStatus === 'UNPRICED' + && detail?.summary?.channelCode === channel.channelCode + context.assert(MODULE, '真实渠道变更保留 WAIT_PRICING 且旧费用失效', response, valid, + `status=${detail?.summary?.processStatus}, feeStatus=${detail?.summary?.feeStatus}`, OPTIONS) + if (!valid) throw new Error('WAIT_PRICING 渠道变更事实未收敛') + const listResponse = await context.request('GET', + `/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=WAIT_PRICING&page=1&size=20`) + const page = context.apiSuccess(MODULE, '按 WAIT_PRICING 筛选本轮样本', listResponse, '', OPTIONS) + const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'WAIT_PRICING') + context.assert(MODULE, '待计费筛选包含本轮真实样本', listResponse, visible, `inboundId=${inboundId}`, OPTIONS) + if (!visible) throw new Error('WAIT_PRICING 列表未包含本轮样本') + } finally { + context.apiSuccess(MODULE, '退出渠道目录客户独立会话', + await context.request('POST', '/api/auth/logout', { refreshToken: login.refreshToken }, token), '', OPTIONS) + } +} + async function prepareInboundStatus(context, target) { const originalMarker = context.marker const originalRequest = context.request const fixtureMarker = `${originalMarker}-IN-${target}` const stop = Symbol(target) const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources'] - const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'orderStopAtApproved'] const createdSnapshot = new Map(createdKeys.map((name) => [name, { present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }])) const contextSnapshot = new Map(contextKeys.map((name) => [name, { present: Object.hasOwn(context.context, name), value: context.context[name] }])) try { context.marker = fixtureMarker + if (target === 'WAIT_PRICING') context.context.orderStopAtApproved = true const orders = await import(new URL('./orders.mjs', import.meta.url)) await orders.run(context) const order = { ...context.createdResources.orders } @@ -206,7 +330,7 @@ async function prepareInboundStatus(context, target) { context.request = async (method, path, body, token, headers) => { const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) - if (target === 'MEASURE_PENDING' && isSubmit) { + if (['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'].includes(target) && isSubmit) { const response = await originalRequest.call(context, method, path, body, token, headers) if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`) throw stop @@ -224,6 +348,13 @@ async function prepareInboundStatus(context, target) { } } + if (['FEE_CONFIRMING', 'WAIT_PRICING'].includes(target)) { + await retainFeeConfirmingFixture(context, { inboundId, tenantId }) + } + if (target === 'WAIT_PRICING') { + await retainWaitPricingFixture(context, { inboundId, tenantId, orderId: id(order.orderId) }) + } + let response = null let detail = null for (let attempt = 1; attempt <= 8; attempt += 1) { @@ -320,7 +451,7 @@ export async function run(context, matrix = context.createdResources.manualState id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`, } - for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) { + for (const status of ['MEASURING', 'MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING', 'WAIT_SPLIT']) { const sample = await prepareInboundStatus(context, status) result.inbounds[status] = sample matrix.pages.inbounds.fixtures[status] = { @@ -335,22 +466,7 @@ export async function run(context, matrix = context.createdResources.manualState } if (!result.inbounds.WAIT_SPLIT) { - const legacyResponse = await context.request('GET', - `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) - const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS) - const legacyInbound = rows(legacyPage)[0] - if (legacyInbound?.id && legacyInbound?.orderId) { - const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId, - status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null } - result.inbounds.WAIT_SPLIT = sample - result.unifiedOrders.WAIT_SPLIT = sample - matrix.pages.inbounds.fixtures.WAIT_SPLIT = { - id: sample.inboundId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`, - } - matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = { - id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`, - } - } + throw new Error('本轮 WAIT_SPLIT 独立样本缺失,不允许借用历史数据') } context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, diff --git a/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs new file mode 100644 index 000000000..a560e1757 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs' + +test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => { + const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' } + const second = { marker: 'ADMIN-API-20261005170914227-COMPLETED-F40-PDA' } + const oldSuffix = value => value.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) + assert.equal(oldSuffix(first), oldSuffix(second)) + assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix(second)) + assert.equal(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first })) + assert.match(pickerFixtureSuffix(first), /^[0-9A-F]{32}$/) + assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first, pdaContainerSuffix: 'B' })) + assert.throws(() => pickerFixtureSuffix({ marker: '' })) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index f66d66301..daa93423b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -7,6 +7,13 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8') +test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => { + assert.match(source, /Approval retains the existing reservation without deducting stock/) + assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/) + assert.doesNotMatch(source, /require approval-transaction deduction/) + assert.match(source, /completed-confirm recovery\/replay/) +}) + test('client services module uses only stable merchant self-service paths', () => { for (const path of [ '/api/merchant/v1/last-leg/orders', @@ -77,14 +84,14 @@ test('focused client services keeps the marker-owned merchant fixture when reusa assert.ok(fixture >= 0 && fixture < clientServices) }) -test('discard follows formal approval deduction and keeps physical ledger evidence pending', () => { - assert.match(source, /正式废弃合同要求审批事务扣减一箱/) +test('discard approval retains reservation and keeps actual confirmation evidence pending', () => { + assert.match(source, /批准只保留锁箱且库存数量流水不变/) assert.match(source, /废弃拒绝释放原箱且全部库存数量流水不变/) assert.match(source, /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/) assert.doesNotMatch(source, /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\//) }) -for (const fault of ['', 'pending deducts', 'reject does not release', 'approve does not deduct', 'create conflict mutates']) { +for (const fault of ['', 'pending deducts', 'reject does not release', 'approve deducts', 'create conflict mutates']) { test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => { let state = 'PENDING_REVIEW', approved = false, conflictSeen = false const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[], @@ -103,7 +110,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}} approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}} } else if(path.endsWith('/balances')) { - const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault!=='approve does not deduct') || (conflictSeen && fault==='create conflict mutates') + const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault==='approve deducts') || (conflictSeen && fault==='create conflict mutates') data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]} } else if(path.endsWith('/ledger')) data={total:'0',records:[]} else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}] @@ -111,7 +118,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve return {http:200,body:{code:'200',data}} }} await assert.rejects(verifyDiscardApproval(context,'merchant',{idempotencyKey:'key',remark:'fixture'},'OLD',proof), - fault ? /仅锁定|释放原箱|审批事务扣减|异载荷拒绝后/ : /CHECKPOINT_PASSED/) + fault ? /仅锁定|释放原箱|只保留锁箱|异载荷拒绝后/ : /CHECKPOINT_PASSED/) assert.ok(!calls.some(call=>call.raw.includes('/pda/'))) }) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 813d9455b..7c34323a7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -249,7 +249,7 @@ export async function verifyDiscard(context, token, markerOrders) { sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId), warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode, skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING', - contract: 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.', + contract: 'Approval retains the existing reservation without deducting stock. Actual PDA confirmation deducts the confirmed quantity; completed confirmation replay must not write again. Review has no idempotencyKey.', } const before = await discardSnapshot(context, token, proof) proof.snapshots.before = before @@ -358,7 +358,7 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId) && stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved)) proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING' - proof.remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger' + proof.remainingEvidence = 'Approval reservation only. Actual PDA confirm, completed-confirm recovery/replay and independent ll_dropship_carton/ll_dropship_sku_inventory/ll_dropship_carton_ledger evidence remain required; public ledger is not carton ledger.' } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index e0e06d9fa..22b0281e2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -1,5 +1,7 @@ import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs' +import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' + const MODULE = 'phase4-overseas-dispatch' const SCENARIO = 'phase4-overseas-dispatch-lifecycle' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -308,6 +310,7 @@ export async function run(context) { context.assert(MODULE, '正式锁释放后候选恢复', freed.response, Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS) context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo + await verifyGroupedDispatch(context, MODULE, selectedCandidates) const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) const snapshotBody = { draftToken, planIds, containersJson } diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 782c279e6..ef7f9d3ca 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -1,3 +1,4 @@ +import { exportWorkbook } from './download-center-fixture.mjs' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' @@ -104,12 +105,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) }, async request(...args) { + if (args[1].startsWith('/api/platform/v1/files/export-tasks')) { + calls.push({ gateway: 'domestic', method: args[0], path: args[1], body: args[2] }) + return ok({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' }) + } const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) { calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') if (token === 'deny-overseas-token') return forbidden() if (path.includes('/fleet/vehicles/')) { + if (method === 'POST' && path.endsWith('/records/finance-status')) { + return ok(body.recordNos.map(recordNo => ({ recordNo, status: processingMarksPaid ? 'PAID' : 'UNLINKED', + sourceVersion: null, submittedRecordVersion: null, finance: null }))) + } const url = new URL(path, 'https://fixture.invalid'), parts = url.pathname.split('/records'), owner = parts[0].split('/').at(-1) const suffix = parts[1], record = careRows.get(suffix.split('/')[1]) if (record && owner !== record.vehicleId && !careLeak) return { http: 200, body: { code: 'A0404' } } @@ -117,7 +126,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = const prior = [...careRows.values()].find(value => value.key === body.idempotencyKey && value.vehicleId === owner) if (prior) return ok(structuredClone(prior)) const created = { recordNo: `VR-${careRows.size + 1}`, vehicleId: owner, details: structuredClone(body.details), files: [], recordVersion: 0, key: body.idempotencyKey, - paymentStatus: ['REPAIR', 'RESCUE'].includes(body.details.recordType) ? 'UNLINKED' : null } + paymentStatus: null } careRows.set(created.recordNo, created); careHistory.set(created.recordNo, [{ snapshot: structuredClone(created) }]); return ok(structuredClone(created)) } if (method === 'GET' && !suffix) { @@ -130,7 +139,6 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = if (method === 'PUT') { if (Number(body.recordVersion) !== record.recordVersion) return conflict() record.details = structuredClone(body.details); record.recordVersion++ - if (processingMarksPaid && record.details.incident?.processingStatus === 'COMPLETED') record.paymentStatus = 'PAID' careHistory.get(record.recordNo).unshift({ reason: body.reason, snapshot: structuredClone(record) }); return ok(structuredClone(record)) } } @@ -286,13 +294,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') if (path.includes('/fleet/vehicles/')) return { http: 200, contentType: 'image/png', bytes: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') } - assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) - let csv = '' - if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` - else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` - else csv = `车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},${state.vehicle.maxPalletSpaces},${state.vehicle.maxLoadKg},${state.vehicle.maxVolumeCbm},2026-08-21\r\n` - return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), - contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } + assert.match(path, /files\/export-tasks\/[^/]+\/download$/) + let headers, values + if (path.includes('OVERSEAS_QUOTE_LIST')) { + headers = ['报价名称', '合作仓编码', '合作仓名称', '报价方'] + values = [state.quote.summary.name, state.quote.summary.warehouseCode, state.quote.summary.warehouseName, state.quote.summary.providerName] + } else if (path.includes('MONTHLY_EXCHANGE_RATE_LIST')) { + headers = ['月份', '月平均汇率', '状态'] + values = [state.exchange.month, state.exchange.usdToCnyAverage, state.exchange.status] + } else { + headers = ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)', '状态'] + values = [state.vehicle.plateNumber, state.vehicle.maxPalletSpaces, state.vehicle.maxLoadKg, state.vehicle.maxVolumeCbm, state.vehicle.status] + } + return { method, path, http: 200, bytes: exportWorkbook(headers, values), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } }, } return { context, state } @@ -304,20 +319,20 @@ test('静态锁定统一Gateway主数据生命周期、CSV与安全边界', () = assert.match(source, /context\.request\('POST'/) assert.match(source, /context\.request\('PUT'/) assert.match(source, /context\.request\('PATCH'/) - assert.match(source, /context\.requestBinary\('GET'/) + assert.match(source, /verifyDownloadCenterExport\(context/) assert.match(source, /context\.expectConflict/) assert.match(source, /warehouse-options\?refresh=true/) assert.match(source, /空候选不负缓存30分钟/) assert.match(source, /30分钟正缓存与刷新快照一致/) assert.match(source, /无quote:list权限访问海外报价被拒绝/) - assert.match(source, /表头与marker内容/) + assert.match(source, /verifyDownloadCenterExport/) assert.match(source, /finally\s*\{/) assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) assert.doesNotMatch(source, /context\.addCoverageGap/) assert.match(source, /2090 \+ Math\.floor/) assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) - for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', - 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) + for (const endpoint of ['overseas-quotes/warehouse-options', 'OVERSEAS_QUOTE_LIST', + 'MONTHLY_EXCHANGE_RATE_LIST', 'VEHICLE_LIST']) assert.ok(source.includes(endpoint)) assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/) assert.doesNotMatch(source, /Idempotency-Key/) }) @@ -334,7 +349,7 @@ test('API dropping updated vehicle capacity fails even with a correct CSV and cl } await run(context) assert.equal(context.steps.find(step => step.name === '车辆编辑保持三项实际容量')?.passed, false) - assert.equal(context.steps.find(step => step.name === '导出marker车辆CSV表头与marker内容')?.passed, true) + assert.equal(context.steps.find(step => step.name === 'VEHICLE_LIST表头与marker内容')?.passed, true) assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') }) @@ -358,7 +373,7 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.role.status, 'DISABLED') assert.equal(state.user.status, 'DISABLED') assert.equal(state.optionReads, 4) - assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export?')).length, 3) + assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export-tasks/') && call.path.endsWith('/download')).length, 3) assert.equal(resource.vehicle.care.status, 'VERIFIED') assert.equal(state.spareVehicle.status, 'INACTIVE') assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) @@ -395,7 +410,22 @@ test('事件处理完成被错误映射为已付款时,真实验收编排必 const { context } = fakeContext({ softAssertions: true, processingMarksPaid: true }) await run(context) assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING') - assert.ok(context.steps.some(step => step.name === '处理完成保持独立的费用及付款事实' && !step.passed && step.deploymentBlocker)) + assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker)) +}) + +test('UNLINKED付款响应夹带财务单据或提交版本仍阻断,不忽略独立查询证据', async () => { + for (const facts of [{ submittedRecordVersion: 1 }, { finance: { costNo: 'COST-UNEXPECTED' } }]) { + const { context } = fakeContext({ softAssertions: true }) + const request = context.request.bind(context) + context.request = async (...args) => { + const response = await request(...args) + if (args[1].endsWith('/records/finance-status')) Object.assign(response.body.data[0], facts) + return response + } + await run(context) + assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING') + assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker)) + } }) test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 64180bb15..e4e413ccc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -1,3 +1,4 @@ +import { verifyDownloadCenterExport } from './download-center.mjs' import { verifyVehicleCare } from './vehicle-care.mjs' const MODULE = 'phase4-overseas-master-data' @@ -16,19 +17,6 @@ function requireData(context, name, response) { return value } -function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) { - const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : '' - const header = text.split(/\r?\n/, 1)[0] - context.assert(MODULE, name, response, - response?.http === 200 - && String(response.contentType || '').toLowerCase().startsWith('text/csv') - && response.bytes instanceof Uint8Array && response.bytes.length > 0, - `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, - BLOCKER) - context.assert(MODULE, `${name}表头与marker内容`, response, - header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))), - `header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER) -} const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : [] @@ -218,10 +206,7 @@ async function quoteLifecycle(context, resource) { context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse, detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) - const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) - assertBinaryCsv(context, '导出海外报价CSV', csv, - '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', - [id, context.marker]) + await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_QUOTE_LIST', { keyword: context.marker }, ['报价名称', '合作仓编码', '合作仓名称', '报价方'], [context.marker]) return true } @@ -288,10 +273,7 @@ async function exchangeLifecycle(context, resource) { Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestBinary('GET', - `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) - assertBinaryCsv(context, '导出marker月汇率CSV', csv, - '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) + await verifyDownloadCenterExport(context, MODULE, 'MONTHLY_EXCHANGE_RATE_LIST', { monthFrom: month, monthTo: month }, ['月份', '月平均汇率', '状态'], [month, 'INACTIVE']) } function vehiclePayload(context, edited = false) { @@ -378,10 +360,7 @@ async function vehicleLifecycle(context, resource) { Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) - assertBinaryCsv(context, '导出marker车辆CSV', csv, - '车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间', - [createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)]) + await verifyDownloadCenterExport(context, MODULE, 'VEHICLE_LIST', { keyword: createPayload.plateNumber }, ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)'], [createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)]) resource.vehicle.care = {} await verifyVehicleCare(context, MODULE, vehicle, resource.vehicle.care) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index d90c64c69..a48675076 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -7,7 +7,102 @@ import { fileURLToPath } from 'node:url' import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer, crossContainerCartonProbe, normalizeReceivingEvidence, receivingEvidenceValid, receivingCompletionProof, captureReceivingEvidence, receivingEventRecoveryInventoryUnchanged, - probeCartonResolveNegatives, awaitReceivingCandidate } from './phase4-overseas-pda.mjs' + probeCartonResolveNegatives, awaitReceivingCandidate, convertReceivingContainers, + awaitProducerConsumption, awaitPickupReadyCandidates } from './phase4-overseas-pda.mjs' + +function conversionContext({ existing = false, eta = null, wrongIdentity = false } = {}) { + const bookingId = '2108183323464437762', historicalId = '2099376270288678913' + const producer = { containerNo: 'TSTU1800264', containerBookingId: bookingId, + resourceKey: 'THIS-RUN-P4-FBA-SECOND:FBA:TSTU1800264' } + const summary = { containerNo: producer.containerNo, sourceContainerBookingId: wrongIdentity ? historicalId : bookingId, + totalOrderCount: 1, inboundCodeCount: 1, missingInboundCodeCount: 0, + planCount: existing ? 1 : 0, pendingPlanCount: existing ? 0 : 1, allPlansReady: existing } + const historical = { ...summary, sourceContainerBookingId: historicalId, planCount: 1, pendingPlanCount: 0, allPlansReady: true } + const plans = [{ lines: [{ inboundCode: 'THIS-RUN-INBOUND' }], pallets: [{ palletNo: 'PLT-THIS-RUN', warehousePalletId: '88' }] }] + const calls = [], steps = [] + let converted = existing + const context = { marker: 'THIS-RUN', token: 'offline', calls, steps, + createdResources: { phase4OverseasPda: { generatedPalletsByInbound: {} }, phase4TailOrderControls: { generatedPlans: plans } }, + addStep: (_module, name, _response, passed) => steps.push({ name, passed }), + assert: (_module, name, _response, passed) => steps.push({ name, passed }), + request: async (method, path, body, _token, headers) => { + calls.push({ method, path, body, headers }) + let data + if (path.includes('/container-summaries?')) data = { records: [historical, summary] } + else if (path.endsWith(`/container-summaries/${bookingId}`)) data = { summary: converted ? { ...summary, planCount: 1, pendingPlanCount: 0, allPlansReady: true } : summary } + else if (path.endsWith('/container-arrival-times/query')) data = [{ sourceContainerBookingId: body.sourceContainerBookingIds[0], estimatedWarehouseArrivalAt: eta, version: 0 }] + else if (path.endsWith('/expected-arrival')) { + if (existing || path.includes(historicalId)) return { http: 200, body: { code: 'A0409', message: '该柜已生成入库计划,预计到仓时间不可修改' } } + data = { sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: body.estimatedWarehouseArrivalAt, version: 1 } + } else if (path.includes('/container-preview?')) data = { sourceContainerBookingId: bookingId, ready: true, totalOrderCount: 1, + pendingOrderCount: existing ? 0 : 1, existingPlannedPalletCount: existing ? 1 : 0, snapshotVersion: 1, snapshotHash: 'snapshot' } + else if (path.endsWith('/convert-container')) { converted = true; data = plans } + else throw Error(`Unexpected offline request: ${method} ${path}`) + return { http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' } + } } + return { context, producer, bookingId, historicalId } +} + +test('r1 same-number historical booking cannot replace the current producer during ETA and plan conversion', async () => { + const { context, producer, bookingId, historicalId } = conversionContext() + assert.equal(await convertReceivingContainers(context, [producer]), true) + assert.ok(context.steps.every(step => step.passed)) + assert.equal(context.calls[0].path, `/api/platform/v1/last-leg/orders/container-summaries/${bookingId}`) + assert.ok(context.calls.every(call => !call.path.includes(historicalId) && !call.path.includes('container-summaries?'))) + assert.deepEqual(context.calls.filter(call => call.path.endsWith('/convert-container')).map(call => call.body.sourceContainerBookingId), [bookingId, bookingId]) + assert.equal(context.calls.filter(call => call.method === 'PUT').length, 1) +}) + +test('existing current-run plans reuse their ETA without writes; missing locked ETA blocks rather than repairs', async () => { + for (const eta of ['2026-10-10T10:00:00', null]) { + const { context, producer } = conversionContext({ existing: true, eta }) + assert.equal(await convertReceivingContainers(context, [producer]), Boolean(eta)) + assert.equal(context.calls.filter(call => call.method === 'PUT' || call.path.endsWith('/convert-container')).length, 0) + assert.equal(context.steps.every(step => step.passed), Boolean(eta)) + } +}) + +test('missing producer booking or mismatched detail identity fails before any ETA query or write', async () => { + for (const missing of [false, true]) { + const { context, producer } = conversionContext({ wrongIdentity: true }) + if (missing) delete producer.containerBookingId + assert.equal(await convertReceivingContainers(context, [producer]), false) + assert.ok(context.steps.some(step => !step.passed)) + assert.ok(context.calls.every(call => call.method === 'GET')) + } +}) + +test('producer consumption waits for exact booking projection; same-number history is never consumed', async () => { + const { context, producer, historicalId } = conversionContext() + const actualRequest = context.request + let attempt = 0 + const delays = [] + context.request = async (...args) => { + const result = await actualRequest(...args) + if (++attempt === 1) result.body.data.summary.sourceContainerBookingId = historicalId + else result.body.data.summary.sourceContainerBookingId = producer.containerBookingId + return result + } + assert.equal(await awaitProducerConsumption(context, [producer], { attempts: 2, delay: async ms => delays.push(ms) }), true) + assert.deepEqual(delays, [1000]) + assert.equal(context.calls.length, 2) + assert.ok(context.calls.every(call => call.method === 'GET' && call.path.endsWith(`/${producer.containerBookingId}`))) + const wrong = conversionContext({ wrongIdentity: true }) + assert.equal(await awaitProducerConsumption(wrong.context, [wrong.producer], { attempts: 1 }), false) + assert.equal(wrong.context.calls.length, 1) +}) + +test('pickup readiness ignores historical same-number rows in either order and cannot borrow their operable state', async () => { + const { producer, historicalId } = conversionContext() + const current = { containerNo: producer.containerNo, sourceContainerBookingId: producer.containerBookingId, operable: true } + const historical = { ...current, sourceContainerBookingId: historicalId, operable: false } + for (const rows of [[current, historical], [historical, current], [{ ...historical, operable: true }]]) { + const context = candidateContext([candidateResponse(rows)]) + assert.equal(await awaitPickupReadyCandidates(context, 'offline', [producer], [producer], { attempts: 1 }), rows.length === 2) + assert.equal(context.calls.length, 1) + assert.equal(context.calls[0].method, 'GET') + } +}) function candidateContext(responses) { const calls=[],steps=[] diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 7dce2f867..1b6327edc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -1,4 +1,5 @@ import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' +import { createHash } from 'node:crypto' const MODULE = 'phase4-overseas-pda' const SCENARIO = 'phase4-overseas-pda-container-receiving' @@ -47,6 +48,10 @@ function forbidden(response) { return [401, 403].includes(response?.http) || (re function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } +export function pickerFixtureSuffix(context) { + if (typeof context.marker !== 'string' || !context.marker.trim()) throw Error('PDA账号缺少本轮运行标识') + return createHash('sha256').update(JSON.stringify([context.marker, context.pdaContainerSuffix || ''])).digest('hex').slice(0, 32).toUpperCase() +} function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } function receivingSnapshot(data) { return JSON.stringify({ @@ -313,7 +318,7 @@ export async function resolvePickerAccess(context) { export async function createPicker(context, index, overseasWarehouseId, access) { const role = access.role - const suffix = markerSuffix(context) + const suffix = pickerFixtureSuffix(context) const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9` const phone = await context.findAvailablePhone(MODULE) @@ -475,48 +480,56 @@ export async function uploadPhotos(context, token) { return photos } -export async function awaitProducerConsumption(context, producers) { - const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] +export async function awaitProducerConsumption(context, producers, { attempts = 90, delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + const expected = [...new Map(producers.map(item => [id(item?.containerBookingId), item])).values()] if (!expected.length) return true + if (expected.some(item => !longId(item?.containerBookingId) || !nonBlank(item?.containerNo))) return false const consumed = new Set() const lastResponses = new Map() - for (let attempt = 1; attempt <= 90; attempt += 1) { - const missing = expected.filter((containerNo) => !consumed.has(containerNo)) - const results = await Promise.all(missing.map(async (containerNo) => { - const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) - return { containerNo, response } + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const missing = expected.filter(item => !consumed.has(id(item.containerBookingId))) + const results = await Promise.all(missing.map(async (producer) => { + const response = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(producer.containerBookingId)}`) + return { producer, response } })) - for (const { containerNo, response } of results) { - lastResponses.set(containerNo, response) - const rows = records(response?.body?.data) - if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) { - consumed.add(containerNo) + for (const { producer, response } of results) { + const bookingId = id(producer.containerBookingId) + lastResponses.set(bookingId, response) + const summary = response?.body?.data?.summary + if (success(response) && id(summary?.sourceContainerBookingId) === bookingId + && summary?.containerNo === producer.containerNo && Number(summary?.totalOrderCount) > 0) { + consumed.add(bookingId) } } - if (expected.every((containerNo) => consumed.has(containerNo))) break - await new Promise((resolve) => setTimeout(resolve, 1000)) + if (expected.every(item => consumed.has(id(item.containerBookingId)))) break + if (attempt < attempts) await delay(1000) } - for (const containerNo of expected) { - const response = lastResponses.get(containerNo) || null + for (const producer of expected) { + const containerNo = producer.containerNo, bookingId = id(producer.containerBookingId) + const response = lastResponses.get(bookingId) || null context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response, true, - `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS) + `containerNo=${containerNo}, sourceContainerBookingId=${bookingId}, consumed=${consumed.has(bookingId)}; ${trace(response)}`, OBSERVATION_OPTIONS) } - return expected.every((containerNo) => consumed.has(containerNo)) + return expected.every(item => consumed.has(id(item.containerBookingId))) } export async function convertReceivingContainers(context, producers) { for (const producer of producers) { const containerNo = id(producer?.containerNo) - const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {}) - const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records) - ? summaryResponse.body.data.records : [] - const summary = summaries.find((item) => id(item?.containerNo) === containerNo) + const bookingId = id(producer?.containerBookingId) + if (!longId(bookingId) || !nonBlank(containerNo)) { + context.addStep(MODULE, '本轮生产柜次身份完整', null, false, + `containerNo=${containerNo || 'missing'}, sourceContainerBookingId=${bookingId || 'missing'}`, OPTIONS) + return false + } + const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}`, undefined, context.token, {}) + const summary = success(summaryResponse) ? summaryResponse.body?.data?.summary : null const totalOrders = Number(summary?.totalOrderCount) const inboundCodes = Number(summary?.inboundCodeCount) const existingPlans = Number(summary?.planCount) const pendingPlans = Number(summary?.pendingPlanCount) - const convertible = nonBlank(summary?.sourceContainerBookingId) + const convertible = id(summary?.sourceContainerBookingId) === bookingId && summary?.containerNo === containerNo && totalOrders > 0 && inboundCodes === totalOrders && Number(summary?.missingInboundCodeCount) === 0 && ((existingPlans === 0 && pendingPlans === totalOrders) @@ -525,7 +538,6 @@ export async function convertReceivingContainers(context, producers) { convertible, `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) if (!convertible) return false - const bookingId = id(summary.sourceContainerBookingId) const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {}) const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data) ? arrivalQueryResponse.body.data : [] @@ -534,7 +546,12 @@ export async function convertReceivingContainers(context, producers) { Boolean(arrivalTime) && Number.isInteger(Number(arrivalTime?.version)), `sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalQueryResponse)}`, OPTIONS) - if (!arrivalTime) return false + if (!arrivalTime || !Number.isInteger(Number(arrivalTime.version))) return false + if (existingPlans > 0 && !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) { + context.addStep(MODULE, `已生成计划的整柜预计到仓时间不可补写 ${containerNo}`, arrivalQueryResponse, + false, `sourceContainerBookingId=${bookingId}, plans=${existingPlans}, estimatedWarehouseArrivalAt=missing`, OPTIONS) + return false + } if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) { const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19) const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), @@ -618,29 +635,35 @@ export async function convertReceivingContainers(context, producers) { return true } -async function awaitPickupReadyCandidates(context, token, producers, allProducers) { +export async function awaitPickupReadyCandidates(context, token, producers, allProducers, + { attempts = 30, delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] const expectedSet = new Set(expected) const awaiting = [...new Set(allProducers .filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo))) .map((item) => id(item?.containerNo)).filter(nonBlank))] - const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank)) + const isKnownProducer = candidate => allProducers.some(producer => longId(producer?.containerBookingId) + && id(candidate?.sourceContainerBookingId) === id(producer.containerBookingId) + && candidate?.containerNo === producer.containerNo) if (!expected.length) return false let response = null let candidatesByContainer = new Map() - for (let attempt = 1; attempt <= 30; attempt += 1) { + let unexpectedHistorical = [] + for (let attempt = 1; attempt <= attempts; attempt += 1) { response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {}) const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + unexpectedHistorical = candidateRows.filter(item => !isKnownProducer(item)) + .map(item => `${id(item?.containerNo)}:${id(item?.sourceContainerBookingId)}`) candidatesByContainer = new Map(candidateRows + .filter(isKnownProducer) .map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo))) if (expected.every((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) && awaiting.every((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)) break - await new Promise((resolve) => setTimeout(resolve, 1000)) + if (attempt < attempts) await delay(1000) } const seenExpected = expected.filter((containerNo) => candidatesByContainer.has(containerNo)) const missingPickupReady = expected.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true) const unexpectedAwaiting = awaiting.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) - const unexpectedHistorical = [...candidatesByContainer.keys()].filter((containerNo) => !known.has(containerNo)) const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey) && item?.shippingReady !== false && candidatesByContainer.get(id(item?.containerNo))?.operable === true) const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index 0d5799df5..b1216edc3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -1,3 +1,4 @@ +import { palletReceiptBody } from './return-boundary-scenarios.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -8,6 +9,19 @@ import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) +test('both pallet receipt batches supply SKU box counts and measured pallet fields', () => { + for (const [sku, quantity, startedBy] of [['A', 3, 'RETURN_NO'], ['B', 2, 'SKU']]) { + const body = palletReceiptBody('RT-1', `P-${sku}`, sku, quantity, `KEY-${sku}`, startedBy) + assert.equal(body.unitType, 'PALLET') + assert.equal(body.unitCode, `P-${sku}`) + assert.equal(body.palletCode, body.unitCode) + assert.equal(body.startedBy, startedBy) + assert.deepEqual(body.items, [{ skuCode: sku, receivedQuantity: quantity, boxCount: 1 }]) + for (const field of ['length', 'width', 'height', 'weight']) assert.ok(body[field] > 0) + assert.equal(body.totalBoxCount, undefined, 'server derives the total from SKU box counts') + } +}) + test('relabelled outbound refuses another relabel application before creating an outbound', async () => { const calls = [] const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, @@ -214,12 +228,10 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) assert.match(source, /PDA首次单SKU签收甲3件/) - assert.match(source, /PDA按单SKU完成乙2件签收/) - assert.match(source, /palletCode: cartonA/) - assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) - assert.match(source, /photos\/files/) - assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/) - assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) + assert.match(source, /PDA无照片按单SKU完成乙2件签收/) + assert.match(source, /palletReceiptBody\(returnNo, palletCodeA, skuA, 3,/) + assert.match(source, /const cartonA = id\(first.data\?\.cartonMark\)/) + assert.match(source, /const cartonB = id\(second.data\?\.cartonMark\)/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 016f68832..f45458c3c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -1,5 +1,5 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' -import { runReturnBoundaries, verifyNoMutation } from './return-boundary-scenarios.mjs' +import { runReturnBoundaries, verifyNoMutation, palletReceiptBody } from './return-boundary-scenarios.mjs' const MODULE = 'phase4-overseas-return-receipt' const SCENARIO = 'phase4-overseas-return-receipt-and-billing' @@ -96,6 +96,7 @@ function itemMap(items) { return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) } + export async function approveReturnFixture(context, merchantToken, created) { const expectedCustomer = id(context.context?.merchantQualificationFixture?.tenantId) const order = created?.order @@ -235,16 +236,18 @@ export async function run(context) { const palletB = await overseas(context, picker.token, 'POST', 'PDA生成本轮SKU乙签收托盘', `${PDA_RECEIPTS}/pallets`, { returnNo, skuCode: skuB }) if (!success(palletA.response) || !nonBlank(palletA.data?.palletCode) || !success(palletB.response) || !nonBlank(palletB.data?.palletCode)) throw new Error('签收托盘准备失败') - const cartonA = palletA.data.palletCode + const palletCodeA = palletA.data.palletCode const firstBody = { - returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', palletCode: cartonA, - items: [{ skuCode: skuA, receivedQuantity: 3 }], idempotencyKey: key(context, 'RECEIPT-1'), + ...palletReceiptBody(returnNo, palletCodeA, skuA, 3, key(context, 'RECEIPT-1'), 'RETURN_NO'), + trackingNo: `TRACK-${suffix}`, } const first = await overseas(context, picker.token, 'POST', 'PDA首次单SKU签收甲3件', `${PDA_RECEIPTS}/confirm`, firstBody) if (!success(first.response)) throw new Error('首次签收失败,停止后续库存场景') + const cartonA = id(first.data?.cartonMark) + context.assert(MODULE, '托盘签收返回独立实物箱码', first.response, nonBlank(cartonA) && cartonA !== palletCodeA, trace(first.response), OPTIONS) context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false - && Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === cartonA + && Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === palletCodeA && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo), `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const firstReceiptNo = id(first.data?.receiptNo) @@ -254,7 +257,8 @@ export async function run(context) { context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, id(firstReplay.data?.receiptNo) === firstReceiptNo && id(firstReplay.data?.receiptBatchNo) === firstBatchNo - && id(firstReplay.data?.palletCode) === cartonA, + && id(firstReplay.data?.palletCode) === palletCodeA + && id(firstReplay.data?.cartonMark) === cartonA, trace(firstReplay.response), OPTIONS) await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, @@ -262,8 +266,7 @@ export async function run(context) { await reject('超收被拒绝且不得生成费用批次', conflict, async () => { const body = { - returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], - idempotencyKey: key(context, 'OVER-RECEIPT'), + ...palletReceiptBody(returnNo, palletCodeA, skuA, 2, key(context, 'OVER-RECEIPT'), 'SKU'), } let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { @@ -275,11 +278,12 @@ export async function run(context) { }) for (const [name, changes, expected] of [ - ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], - ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1 }] }, validationFailure], + ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0, boxCount: 1 }] }, validationFailure], + ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1, boxCount: 1 }] }, validationFailure], + ['零SKU箱数', { items: [{ skuCode: skuB, receivedQuantity: 1, boxCount: 0 }] }, validationFailure], ['空实收明细', { items: [] }, validationFailure], ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], - ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], + ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1, boxCount: 1 }] }, conflict], ]) { await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {})) } @@ -298,26 +302,9 @@ export async function run(context) { `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) const secondBody = { - returnNo, startedBy: 'SKU', palletCode: palletB.data.palletCode, - items: [{ skuCode: skuB, receivedQuantity: 2 }], - idempotencyKey: key(context, 'RECEIPT-2'), + ...palletReceiptBody(returnNo, palletB.data.palletCode, skuB, 2, key(context, 'RECEIPT-2'), 'SKU'), } - await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => { - let response = null - for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {}) - if (response?.http !== 0 || attempt === 3) break - await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) - } - return response - }) - const photoForm = new FormData() - // Synthetic HTTP fixture, not a photograph of real warehouse activity. - photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') - const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) - add(context, '上传本轮合成打托测试照片', photo, success(photo)) - if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败') - const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + const second = await overseas(context, picker.token, 'POST', 'PDA无照片按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) if (!success(second.response)) throw new Error('最终签收失败,停止合箱和换标后续') context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true @@ -327,7 +314,7 @@ export async function run(context) { `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const secondReceiptNo = id(second.data?.receiptNo) const secondBatchNo = id(second.data?.receiptBatchNo) - const cartonB = id(second.data?.palletCode) + const cartonB = id(second.data?.cartonMark) context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo) context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo) context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) @@ -400,18 +387,18 @@ export async function run(context) { const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存', query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo })) const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row])) - context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response, + context.assert(MODULE, '实物箱库存不混入散SKU合箱候选', packing.response, packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED') - && Number(packingSkus.get(skuA)?.availableQuantity) === 3 - && Number(packingSkus.get(skuB)?.availableQuantity) === 2, + && !packingSkus.has(skuA) && !packingSkus.has(skuB), trace(packing.response), OPTIONS) const mergeBody = { - returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }], + sourceCartonMarks: [cartonA, cartonB], + sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 1 }, { cartonMark: cartonB, skuCode: skuB, quantity: 1 }], length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'), } const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱', - '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody) if (success(merged.response)) { context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 @@ -443,26 +430,26 @@ export async function run(context) { && / context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {})) + await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, weight: 3 }, picker.token, {})) const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) for (const [name, changes, expected] of [ ['零箱长', { length: 0 }, validationFailure], ['负重量', { weight: -1 }, validationFailure], - ['空合箱明细', { items: [] }, validationFailure], - ['零合箱数量', { items: [{ skuCode: skuA, quantity: 0 }] }, validationFailure], - ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], - ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], + ['空来源箱', { sourceCartonMarks: [] }, validationFailure], + ['零合箱数量', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 0 }] }, validationFailure], + ['超可用库存', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 999 }] }, conflict], + ['不存在来源箱', { sourceCartonMarks: [`MISSING-${suffix}`] }, conflict], ]) { - await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) + await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) } await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', - '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody) const afterInvalid = await overseas(context, merchantToken, 'GET', '回查异常及重放后的全部箱库存', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) context.assert(MODULE, '失败与幂等重放不改变箱数数量状态及来源', afterInvalid.response, diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 05c8e17db..94275e0c7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -1,3 +1,4 @@ +import { exportWorkbook } from './download-center-fixture.mjs' import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' @@ -17,6 +18,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, async request(...args) { + if (args[1].startsWith('/api/platform/v1/files/export-tasks')) { + calls.push({ channel: 'domestic', method: args[0], path: args[1], body: args[2] }) + return response({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' }) + } const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); const handler = route ? async function(method,requestPath,body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) @@ -58,7 +63,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou return handler.apply(this,args); }, - async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, + async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: exportWorkbook(['入库单号', 'SKU', '可用箱数', '发生时间', '业务单号', '变动箱数']), contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'request-binary', traceId: 'trace-binary' } }, apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, @@ -70,7 +75,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.match(source, /export async function run\(context\)/) assert.match(source, /context\.request\('GET'/) - assert.match(source, /context\.requestBinary\('GET'/) + assert.match(source, /verifyDownloadCenterExport\(context/) assert.doesNotMatch(source, /https?:\/\//) assert.doesNotMatch(source, /process\.env|fetch\(/) }) @@ -80,7 +85,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => { return run(context).then(() => { assert.ok(context.calls.length >= 12) assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) - assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3) + assert.equal(context.calls.filter((call) => call.channel === 'domestic' && !call.path.includes('/files/export-tasks')).length, 3) assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?'))) assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false) @@ -89,7 +94,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => { assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets'))) assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false) - assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/'))) + assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/') || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks'))) for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { const step = context.steps.find((item) => item.name === name) assert.ok(step?.passed, name) @@ -113,7 +118,7 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => { for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) } - for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { + for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?'))) { const params = new URL(`http://acceptance${call.path}`).searchParams assert.equal(params.has('page'), false, call.path) assert.equal(params.has('size'), false, call.path) @@ -141,11 +146,11 @@ test('缺少客户时明确失败,缺少仓库时通过正式接口创建并 }) test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { - assert.match(source, /inventory\/balances\/export/) - assert.match(source, /inventory\/ledger\/export/) + assert.match(source, /OVERSEAS_INVENTORY_BALANCE/) + assert.match(source, /OVERSEAS_INVENTORY_LEDGER/) assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/) - assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/) - assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/) + assert.match(source, /OVERSEAS_INVENTORY_BALANCE[^\n]+\{ customerId \}/) + assert.match(source, /OVERSEAS_INVENTORY_LEDGER[^\n]+\{ customerId \}/) for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /requestId=/) assert.match(source, /traceId=/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 58430d4c6..123b0dcc0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -1,3 +1,4 @@ +import { verifyDownloadCenterExport } from './download-center.mjs' const MODULE = 'phase4-overseas-warehouse' const SCENARIO = 'phase4-overseas-warehouse' const OPTIONS = { scenario: SCENARIO } @@ -47,13 +48,6 @@ async function domesticMutation(context, method, name, path, body) { context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) return { response, data: passed ? response.body?.data : null } } -async function overseasCsv(context, name, path) { - const response = await context.requestBinary('GET', path) - context.addStep(MODULE, name, response, - response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'), - `bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS) - return response -} function assertPage(context, name, result) { context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) @@ -151,8 +145,8 @@ export async function run(context) { && Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages)) && Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt), `id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS) - await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId })) - await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId })) + await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_BALANCE', { customerId }, ['入库单号', 'SKU', '可用箱数']) + await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_LEDGER', { customerId }, ['发生时间', '业务单号', '变动箱数']) let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) let ownedWarehouse = false diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index bd552627d..8f42f9fec 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -25,7 +25,7 @@ test('tail order controls execute after current producer and before overseas PDA }) test('module covers the public tail-order write surface before PDA receiving', () => { - for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export', + for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', 'LAST_MILE_ORDER_LIST', '/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) { assert.ok(source.includes(path), `missing ${path}`) } @@ -33,7 +33,7 @@ test('module covers the public tail-order write surface before PDA receiving', ( '接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409', '整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓', '单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组', - '尾程费用详情可追溯', '导出本轮尾程订单CSV', + '尾程费用详情可追溯', 'LAST_MILE_ORDER_LIST', '暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划', '解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放', '人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点', @@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', ( ]) assert.ok(source.includes(evidence), `missing ${evidence}`) assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) assert.match(source, /creationMode === 'AUTO'/) - assert.match(source, /requestBinary\('GET'/) + assert.match(source, /verifyDownloadCenterExport\(context/) assert.match(source, /palletMergeBatchId/) assert.match(source, /feeItemCode/) assert.match(source, /feeItemName/) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index c54e81d68..c9880a7f0 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -1,3 +1,4 @@ +import { verifyDownloadCenterExport } from './download-center.mjs' const MODULE = 'phase4-tail-order-controls' const SCENARIO = 'phase4-tail-order-controls-lifecycle' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -207,14 +208,9 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) - const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, { + const exported = await verifyDownloadCenterExport(context, MODULE, 'LAST_MILE_ORDER_LIST', { customerId: id(representative.customerId), containerNo: producer.containerNo, - })) - const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) - context.addStep(MODULE, '导出本轮尾程订单CSV', exported, - exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv') - && csv.includes(producer.containerNo) && csv.includes('尾程单号'), - `bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS) + }, ['尾程单号', '集装箱号'], [producer.containerNo]) return success(batchCleared.response) && success(fee.response) && exported?.http === 200 } function manualPlanBody(order, version, marker) { diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 8ca8cf005..bd0dce82d 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, - completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, + completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -10,6 +10,23 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8') +test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => { + const expected = { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', inboundCode: 'IN-1', evidenceFileId: '91' } + const detail = { order: { ...expected, status: 'COMPLETED', packageCount: 1, evidenceFileIds: ['91'] }, + returnSources: [], returnReceipts: [{ receiptNo: 'RSR-1', receivedPackages: '1', receivedAt: '2026-10-06T01:00:00' }] } + const delivery = { order: { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', packageCount: 4, status: 'RETURN_PENDING' } } + assert.equal(returnedExceptionFactsMatch(detail, delivery, expected), true) + assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, packageCount: 1, status: 'RETURNED' } }, expected), true) + assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, status: 'RETURNED' } }, expected), false) + assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, customerId: 'OTHER' } }, expected), false) + for (const mutate of [v => { v.returnReceipts = [] }, v => { v.returnReceipts[0].receivedPackages = 2 }, + v => { v.order.outboundNo = 'OB-OTHER' }, v => { v.order.evidenceFileIds = [] }, + v => { v.returnSources = [{ maxReturnPackages: 1 }] }, v => { v.returnReceipts.push({ ...v.returnReceipts[0] }) }]) { + const changed = structuredClone(detail); mutate(changed) + assert.equal(returnedExceptionFactsMatch(changed, delivery, expected), false) + } +}) + test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{ const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']} const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'}, @@ -20,6 +37,7 @@ test('route equals actual item destinations and frozen selected candidates, then for(const mutate of [ value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER', value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER', + value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED', value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10', value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(), value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}), @@ -218,9 +236,10 @@ test('same dispatch-return exception reaches actual restored inventory and retri const replayed = completed; completed = true return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed }) } - if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } }) + if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1 } }) return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN', - outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] }) + outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1, version, evidenceFileIds: ['91'] }, + returnSources: completed ? [] : [source], returnReceipts: completed ? [{ receiptNo: 'RSR-CURRENT', receivedPackages: 1, receivedAt: '2026-10-06T00:00:00' }] : [] }) }) context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) } context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 559961135..8bf7373d4 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -1,6 +1,7 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' import { run as createMerchant } from './merchant-qualification-fixture.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' +import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -68,6 +69,8 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id) if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length || new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false + if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode) + && id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode)) const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(',')) if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false @@ -444,6 +447,26 @@ export async function verifyPartialOrderCompletion(context, { orderId, customerI return sameOrder && blocked } +export function returnedExceptionFactsMatch(detail, delivery, expected) { + const order = detail?.order, shipment = delivery?.order + const receipts = detail?.returnReceipts + if (order?.status !== 'COMPLETED' || order.outboundNo !== expected.outboundNo + || order.deliveryNo !== expected.deliveryNo || order.inboundCode !== expected.inboundCode + || id(order.customerId) !== expected.customerId + || !order.evidenceFileIds?.some(fileId => id(fileId) === expected.evidenceFileId) + || !Array.isArray(detail.returnSources) || detail.returnSources.length !== 0 + || !Array.isArray(receipts) || receipts.length === 0 + || new Set(receipts.map(row => row.receiptNo)).size !== receipts.length + || receipts.some(row => !nonBlank(row.receiptNo) || !nonBlank(row.receivedAt) + || !Number.isSafeInteger(Number(row.receivedPackages)) || Number(row.receivedPackages) <= 0)) return false + const received = receipts.reduce((sum, row) => sum + Number(row.receivedPackages), 0) + const sent = Number(shipment?.packageCount) + return received === Number(order.packageCount) && Number.isSafeInteger(sent) && sent >= received + && shipment.outboundNo === expected.outboundNo && shipment.deliveryNo === expected.deliveryNo + && id(shipment.customerId) === expected.customerId + && shipment.status === (received === sent ? 'RETURNED' : 'RETURN_PENDING') +} + export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) { const owned = context.createdResources?.phase5OverseasFulfillment if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) { @@ -566,13 +589,10 @@ export async function completeReturnedException(context, { customerId, warehouse const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath) const delivery = await platform(context, 'GET', '回查退回实收后的原派送单', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId })) - context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response, - final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo - && final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId) - && final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId) - && id(row.inventoryPositionId) === id(source.inventoryPositionId)) - && delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo, - trace(final.response), OPTIONS) + context.assert(MODULE, '退回实收保留原出库和收货证据,派送状态按实际退回数量推进', final.response, + returnedExceptionFactsMatch(final.data, delivery.data, { customerId, outboundNo, + deliveryNo: order.deliveryNo, inboundCode: order.inboundCode, evidenceFileId }), + `approved=${final.data?.order?.packageCount}, sent=${delivery.data?.order?.packageCount}, status=${delivery.data?.order?.status}; ${trace(final.response)}`, OPTIONS) return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages), targetPositionIds: targetPositions.map(row => id(row.id)) } @@ -1232,6 +1252,8 @@ export async function run(context) { exceptionNo: returnExceptionNo, outboundNo }) context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) + await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], + businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true }) context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index faf894e84..413c509df 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -2,16 +2,29 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, - completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials, + completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials, f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, - verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs' + verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, + resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs' import { loadOneFragment } from './phase3-order-splitting.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse } from './phase4-overseas-pda.mjs' const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8') + +test('根完成UTC时刻与上海本地签收最大值一致,拒绝时区偏移、较早来源和缺失证据', () => { + const times = ['2026-10-05T21:55:00.000', '2026-10-05T21:56:07.922'] + assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', times), true) + assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', [...times].reverse()), true) + assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', ['2026-10-05T21:56:07.922+08:00']), true) + for (const value of ['2026-10-05T21:56:07.922', '2026-10-05T13:55:00', null]) { + assert.equal(rootCompletionTimeMatches(value, times), false) + } + assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', []), false) + assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', ['invalid']), false) +}) const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action})) const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2, currentRemainderPackageCount:0,children:[ @@ -285,6 +298,85 @@ test('exported receiving warehouse resolver continues to choose only configured ]}}}) assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'}) }) + +function warehouseSelectionFixture() { + const producers = [1, 2].map(n => ({ tenantId: '90', containerNo: `CONTAINER-${n}`, + containerBookingId: `BOOKING-${n}`, sourceInboundId: `INBOUND-${n}`, inboundCode: `CODE-${n}` })) + const orders = producers.map((p, n) => ({ id: `LASTLEG-${n}`, customerId: p.tenantId, + sourceContainerBookingId: p.containerBookingId, sourceInboundId: p.sourceInboundId, + inboundCode: p.inboundCode, containerNo: p.containerNo, overseasWarehouseId: 'actual-warehouse', + overseasWarehouseCode: 'ACTUAL' })) + const warehouses = [{ id: 'other-warehouse', warehouseName: 'other', warehouseCode: 'OTHER', + warehouseType: 'OVERSEAS', status: 'ACTIVE' }, + { id: 'actual-warehouse', warehouseName: 'actual', warehouseCode: 'ACTUAL', + warehouseType: 'OVERSEAS', status: 'ACTIVE' }] + const calls = [], context = fixture() + context.request = async (method, path) => { + calls.push({ method, path }) + const rows = path.includes('/warehouse-admin?') ? warehouses + : orders.filter(row => row.containerNo === new URL(path, 'https://offline.invalid').searchParams.get('containerNo')) + return { http: 200, body: { code: 200, data: { records: rows, total: rows.length } } } + } + return { producers, orders, warehouses, calls, context } +} + +test('F40 selects actual source warehouse irrespective of authority list order and only performs GET', async () => { + for (const reverse of [false, true]) { + const f = warehouseSelectionFixture() + if (reverse) { f.warehouses.reverse(); f.orders.reverse(); f.producers.reverse() } + const warehouse = await resolveF40ReceivingWarehouse(f.context, f.producers) + assert.equal(warehouse.id, 'actual-warehouse') + assert.equal(warehouse.code, 'ACTUAL') + assert.equal(warehouse.sourceOrders.length, 2) + assert.ok(f.calls.every(row => row.method === 'GET')) + } + assert.ok(source.indexOf('await awaitProducerConsumption(scope,producers)') + < source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)')) + assert.ok(source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)') + < source.indexOf('await createPicker(scope, 7')) + assert.doesNotMatch(source, /resolveSharedWarehouse|19300000000020000[34]/) +}) + +test('F40 warehouse resolution rejects wrong customer, booking, inbound identity, duplicates and missing assignment', async () => { + for (const mutate of [ + f => { f.orders[0].customerId = 'foreign' }, + f => { f.orders[0].sourceContainerBookingId = 'foreign' }, + f => { f.orders[0].sourceInboundId = 'foreign' }, + f => { f.orders[0].inboundCode = 'foreign' }, + f => { f.orders[0].containerNo = 'foreign' }, + f => { f.orders[0].overseasWarehouseId = '' }, + f => { f.orders.push({ ...f.orders[0] }) }, + ]) { + const f = warehouseSelectionFixture(); mutate(f) + await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /来源柜客户入仓身份/) + assert.ok(f.calls.every(row => !row.path.includes('/warehouse-admin?'))) + } +}) + +test('F40 warehouse resolution blocks different receiving warehouses and invalid or ambiguous official master data', async () => { + const mixed = warehouseSelectionFixture(); mixed.orders[1].overseasWarehouseId = 'other-warehouse' + await assert.rejects(resolveF40ReceivingWarehouse(mixed.context, mixed.producers), /同一实际接柜仓/) + for (const mutate of [ + f => { f.warehouses.pop() }, + f => { f.warehouses[1].status = 'DISABLED' }, + f => { f.warehouses[1].warehouseType = 'DOMESTIC' }, + f => { f.warehouses[1].warehouseCode = 'WRONG' }, + f => { f.warehouses.push({ ...f.warehouses[1] }) }, + ]) { + const f = warehouseSelectionFixture(); mutate(f) + await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /唯一启用海外收货仓/) + assert.ok(f.calls.every(row => row.method === 'GET')) + } +}) + +test('F40 warehouse resolution rejects empty or duplicate producers before HTTP', async () => { + for (const select of [() => [], f => [f.producers[0], f.producers[0]], + f => [{ ...f.producers[0], sourceInboundId: '' }]]) { + const f = warehouseSelectionFixture() + await assert.rejects(resolveF40ReceivingWarehouse(f.context, select(f)), /唯.*本轮实装来源/) + assert.equal(f.calls.length, 0) + } +}) test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => { assert.equal(typeof run,'function') for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 3cbf1b110..10808e6b0 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -2,7 +2,7 @@ import { loadOneFragment } from './phase3-order-splitting.mjs' import { freezeOrderCommand } from './orders.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' -import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' import { isDeepStrictEqual } from 'node:util' @@ -19,6 +19,47 @@ const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && String(response?.body?.code) === '200' const query = (path, values) => `${path}?${new URLSearchParams(values)}` +/** Resolve only this run's consumed physical sources, never a fixed warehouse ID or list position. */ +export async function resolveF40ReceivingWarehouse(context, producers) { + check(context, 'F40 选仓仅接受唯一的本轮实装来源', Array.isArray(producers) && producers.length > 0 + && producers.every(row => row?.containerNo && row?.inboundCode && id(row.tenantId) + && id(row.containerBookingId) && id(row.sourceInboundId)) + && new Set(producers.map(row => id(row.containerBookingId))).size === producers.length + && new Set(producers.map(row => id(row.tenantId))).size === 1, 'exact-producer-identities') + const sources = [] + for (const producer of producers) { + const page = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓', + query('/api/platform/v1/last-leg/orders', { page: 1, size: 200, containerNo: producer.containerNo })) + const rows = Array.isArray(page?.records) ? page.records : [] + const matching = rows.filter(row => row.containerNo === producer.containerNo + && id(row.sourceContainerBookingId) === id(producer.containerBookingId) + && id(row.customerId) === id(producer.tenantId) + && id(row.sourceInboundId) === id(producer.sourceInboundId) && row.inboundCode === producer.inboundCode) + check(context, 'F40 尾程来源柜客户入仓身份唯一且接柜仓明确', matching.length === 1 + && (page?.total == null || Number(page.total) <= rows.length) + && Boolean(id(matching[0]?.id) && id(matching[0]?.overseasWarehouseId)), + `container=${producer.containerNo}, exactSources=${matching.length}`) + sources.push(matching[0]) + } + const warehouseId = id(sources[0].overseasWarehouseId) + check(context, 'F40 全部实装来源属于同一实际接柜仓', + sources.every(row => id(row.overseasWarehouseId) === warehouseId), 'unique-source-receiving-warehouse') + const page = await api(context, 'GET', 'F40 按来源仓精确核验Warehouse权威收货主档', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const rows = Array.isArray(page?.records) ? page.records : [] + const matching = rows.filter(row => id(row.id) === warehouseId) + const warehouse = matching[0] + check(context, 'F40 实际接柜仓为唯一启用海外收货仓且编码一致', matching.length === 1 + && warehouse.warehouseType === 'OVERSEAS' && warehouse.status === 'ACTIVE' + && sources.every(row => !row.overseasWarehouseCode || row.overseasWarehouseCode === warehouse.warehouseCode), + `warehouseId=${warehouseId}`) + return { id: warehouseId, name: warehouse.warehouseName || warehouse.name, + code: warehouse.warehouseCode, sourceOrders: sources.map(row => ({ orderId: id(row.id), + customerId: id(row.customerId), sourceContainerBookingId: id(row.sourceContainerBookingId), + sourceInboundId: id(row.sourceInboundId), inboundCode: row.inboundCode, containerNo: row.containerNo, + overseasWarehouseId: id(row.overseasWarehouseId) })) } +} + export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) } export function preserveReceivingEvidence(proof, scope) { proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {} @@ -128,6 +169,19 @@ export function validSignatureGate(snapshot, firstSource, allSigned = false) { && snapshot?.rootInbound?.processStatus === 'DELIVERED' && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' } +// TEST Lastleg runs with TZ=Asia/Shanghai. Its public signedAt is local; +// the completion event and Order projection preserve the same instant in UTC. +export function rootCompletionTimeMatches(completedAt, signedTimes) { + const instant = (value, offset) => { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) return NaN + return Date.parse(value + (/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? '' : offset)) + } + if (!Array.isArray(signedTimes) || signedTimes.length === 0) return false + const signatures = signedTimes.map(value => instant(value, '+08:00')) + return signatures.every(Number.isFinite) && Number.isFinite(instant(completedAt, 'Z')) + && instant(completedAt, 'Z') === Math.max(...signatures) +} + export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) { const expected = [...new Set(sourceOrderIds.map(id))].sort() const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] @@ -613,8 +667,13 @@ async function runRootFulfillment(context) { && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) + const producers = cancelRemainder ? [first] : [first,second] + proof.currentStage = 'OVERSEAS_SOURCE_WAREHOUSE' + check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`) + const warehouse = await resolveF40ReceivingWarehouse(scope,producers) + proof.receivingWarehouseResolution = warehouse proof.currentStage = 'OVERSEAS_PDA_IDENTITY' - const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) + const access = await resolvePickerAccess(scope) check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') scope.marker = `${scope.context.f40BaseMarker}-PDA` const picker = await createPicker(scope, 7, warehouse.id, access) @@ -622,7 +681,6 @@ async function runRootFulfillment(context) { check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') picker.warehouseId = warehouse.id check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') - const producers = cancelRemainder ? [first] : [first,second] scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers } scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], @@ -630,7 +688,6 @@ async function runRootFulfillment(context) { receivingCompletionEvidence:{} } preserveReceivingEvidence(proof,scope) proof.currentStage = 'OVERSEAS_PLAN_CONVERSION' - check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`) check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`) const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers for (const [index, producer] of signatureSources.entries()) { @@ -663,7 +720,7 @@ async function runRootFulfillment(context) { const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable) const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1) - check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt, + check(scope,'F40 根完成时间为实际有效来源签收时间最大值', rootCompletionTimeMatches(stable.root.rawOrder?.completedAt, proof.signatures.map(row => row.signedAt)), `completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`) const reader = context.context?.f40ReadOnlyOutboxEvidence const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId], diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index da1505fc2..e549cbfc0 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -132,14 +132,14 @@ test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限 assert.match(source, /sameKeyResponses/) }) -test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async () => { +test('边界fixture遵守审核、托盘实收和可选照片新契约', async () => { const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') assert.match(source, /customerByToken\.set\(tokenB, String\(fixtureB\.tenantId\)\)/) assert.match(source, /'X-Customer-Id': customerId/) assert.match(source, /status !== 'PENDING_REVIEW'/) - assert.match(source, /palletCode: order\.palletCode/) + assert.match(source, /unitType: 'PARCEL', unitCode: order.unitCode/) assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/) - assert.match(source, /两个合法SKU同批签收仍整笔拒绝/) - assert.match(source, /palletCode: nextPallet\.palletCode/) + assert.match(source, /两个合法SKU同包裹签收生成独立散件库存/) + assert.match(source, /unitCode: `\$\{order.unitCode\}-SECOND`/) assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/) }) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 514596acd..b7a938f18 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -47,6 +47,14 @@ export function exactReceiptLedger(data, expected) { && JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project))) } +export function palletReceiptBody(returnNo, palletCode, skuCode, receivedQuantity, idempotencyKey, startedBy) { + return { + returnNo, palletCode, unitType: 'PALLET', unitCode: palletCode, startedBy, + length: 120, width: 100, height: 80, weight: 10, + items: [{ skuCode, receivedQuantity, boxCount: 1 }], idempotencyKey, + } +} + export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) { let sequence = 0 const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}` @@ -92,26 +100,24 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker if (!operator?.token) throw new Error('缺少本仓签收操作员') - const pallet = await good(operator.token, 'POST', '准备边界退货实收托盘', `${RECEIPTS}/pallets`, { returnNo: data.order.returnNo, skuCode: sku }) - if (!pallet.palletCode) throw new Error('退货托盘码缺失') const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') - return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode } + return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { - return { returnNo: order.returnNo, startedBy: 'RETURN_NO', palletCode: order.palletCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } + return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } } async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) - check('签收单批次采用独立完整编号且绑定本轮托盘', null, + check('签收单批次采用独立完整编号且绑定本轮包裹', null, /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) - && data.palletCode === order.palletCode) - order.cartonMark = data.palletCode + && data.unitType === 'PARCEL' && data.unitCode === order.unitCode && !data.palletCode) + order.cartonMark = data.cartonMark order.batch = data.receiptBatchNo - resources.cartonMarks.push(data.palletCode) + resources.cartonMarks.push(data.cartonMark) return data } function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) { @@ -124,7 +130,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const detail = await good(order.token, 'GET', '读取订单数量与签收批次快照', `${ORDERS}/${order.returnNo}`) const cartons = await good(order.token, 'GET', '读取全部状态箱快照', url(`${INVENTORY}/cartons`, { returnNo: order.returnNo, page: 1, size: 200 })) const items = [] - for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`)) + for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${encodeURIComponent(carton.cartonMark)}`)) const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 })) const additionalSummaries = [] for (const sku of order.skus.filter(sku => sku !== order.sku)) additionalSummaries.push(await good(order.token, 'GET', @@ -176,8 +182,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await receive(a); await receive(b) for (const [name, path] of [ ['退货单详情', `${ORDERS}/${b.returnNo}`], - ['箱明细', `${INVENTORY}/cartons/${b.cartonMark}`], - ['箱标签', `${INVENTORY}/cartons/${b.cartonMark}/label`], + ['箱明细', `${INVENTORY}/cartons/${encodeURIComponent(b.cartonMark)}`], + ['箱标签', `${INVENTORY}/cartons/${encodeURIComponent(b.cartonMark)}/label`], ]) await unchanged(`客户A禁止读取客户B${name}`, [a, b], () => request(merchantToken, 'GET', path), [403, 404]) for (const [name, path, filter] of [ ['退货单列表', ORDERS, { returnNo: b.returnNo }], @@ -201,7 +207,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const candidates = await good(picker.token, 'POST', '本仓查询异仓SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: order.sku }) check('SKU候选不串仓', null, Array.isArray(candidates.candidates) && candidates.candidates.length === 0) await receive(order, foreignPicker) - await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${order.cartonMark}/label`), [404]) + await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${encodeURIComponent(order.cartonMark)}/label`), [404]) await unchanged('非绑定仓禁止合箱', [order], () => request(picker.token, 'POST', MERGE, mergeBody([order], order.sku, 1)), [404]) }) @@ -228,7 +234,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body) const accepted = sameKeyResponses.find(ok)?.body.data check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted - && ['receiptNo', 'receiptBatchNo', 'palletCode'].every(field => retry.body.data[field] === accepted[field])) + && ['receiptNo', 'receiptBatchNo', 'unitCode', 'cartonMark'].every(field => retry.body.data[field] === accepted[field])) check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder])))) } }) @@ -269,16 +275,16 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await receive(a); await receive(b); await receive(unused) const result = await good(picker.token, 'POST', '同客户多单全量合箱', MERGE, mergeBody([a, b, unused], sku, 5)) resources.cartonMarks.push(result.cartonMark) - const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${result.cartonMark}`) + const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${encodeURIComponent(result.cartonMark)}`) check('多单同SKU合并为一行五件', null, Number(target.totalQuantity) === 5 && target.items.length === 1 && Number(target.items[0].quantity) === 5) check('来源单和批次仅含实际消耗且去重', null, JSON.stringify(String(target.sourceReturnNo).split(',').sort()) === JSON.stringify([a.returnNo, b.returnNo].sort()) && JSON.stringify(String(target.sourceBatchNo).split(',').sort()) === JSON.stringify([a.batch, b.batch].sort())) for (const source of [a, b]) { - const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${source.cartonMark}`) + const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${encodeURIComponent(source.cartonMark)}`) check('耗尽来源箱为MERGED且件数SKU数清零', null, carton.status === 'MERGED' && Number(carton.totalQuantity) === 0 && Number(carton.skuTypes) === 0 && carton.items.length === 0) } - const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${unused.cartonMark}`) + const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${encodeURIComponent(unused.cartonMark)}`) check('选中但未取货来源保持可用一件', null, untouched.status === 'AVAILABLE' && Number(untouched.totalQuantity) === 1) const summary = await good(merchantToken, 'GET', '回查全量合箱SKU汇总', url(`${INVENTORY}/summary`, { skuCode: sku, page: 1, size: 20 })) check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1)) @@ -293,12 +299,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p for (const [name, secondSku, quantity] of [['后项超收', skuB, 3], ['后项未预报', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合签收${name}必须整笔失败`, [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, makeReceipt(secondSku, quantity)), [409]) } - await unchanged('两个合法SKU同批签收仍整笔拒绝', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { - ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }), [409]) - await good(picker.token, 'POST', '甲SKU独立签收准备合箱', `${RECEIPTS}/confirm`, receiptBody(order)) - const skuBPallet = await good(picker.token, 'POST', '乙SKU独立托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: skuB }) - await good(picker.token, 'POST', '乙SKU独立签收准备合箱', `${RECEIPTS}/confirm`, { - ...receiptBody(order), palletCode: skuBPallet.palletCode, items: [{ skuCode: skuB, receivedQuantity: 2 }] }) + await good(picker.token, 'POST', '两个合法SKU同包裹签收生成独立散件库存', `${RECEIPTS}/confirm`, { + ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }) for (const [name, secondSku, quantity] of [['后项超库存', skuB, 3], ['后项不存在', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合合箱${name}必须整笔回滚`, [order], () => request(picker.token, 'POST', MERGE, { ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) @@ -371,7 +373,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await unchanged(`${name}不能调用PDA合箱`, [order], () => request(token, 'POST', MERGE, mergeBody([order], order.sku, 1)), statuses) } for (const [name, token] of [['客户', merchantToken], ['PDA', picker.token]]) { - for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}`, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}/label`, `${PLATFORM_INVENTORY}/movements`]) { + for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}`, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}/label`, `${PLATFORM_INVENTORY}/movements`]) { const validPath = path === PLATFORM_INVENTORY || path.endsWith('/movements') ? url(path, { page: 1, size: 20, customerId: context.context.merchantQualificationFixture.tenantId, skuCode: order.sku }) : path await unchanged(`${name}禁止读取平台退货入口${path.split('/').at(-1)}`, [order], () => request(token, 'GET', validPath), [403]) @@ -395,8 +397,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo }) await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)), check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) }) - const nextPallet = await good(picker.token, 'POST', '流水场景第二批托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: order.sku }) - const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), palletCode: nextPallet.palletCode }) + const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), unitCode: `${order.unitCode}-SECOND` }) const data = await ledger() check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) @@ -420,7 +421,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p && remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2 && Number(remaining.candidates[0].skuReceivedQuantity) === 0) for (const [name, patch] of [ - ['已存在箱号', { palletCode: a.cartonMark }], + ['已存在箱号', { unitType: 'BOX', unitCode: a.cartonMark, items: [{ skuCode: sku, receivedQuantity: 1, boxCount: 1 }] }], ['缺少幂等键', { idempotencyKey: '' }], ['超长快递单号', { trackingNo: 'X'.repeat(129) }], ]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422]) diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 1b2d0c2db..519b2acfb 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -80,7 +80,9 @@ test('container API uses one stable long string identity', () => { assert.match(controller, /convertContainer/) assert.match(adminApi, /sourceContainerBookingId: string/) assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/) - assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/) + assert.match(adminView, /const commandSnapshot = preview\.resourceRetry \?\? preview/) + assert.match(adminView, /containerConversionIdempotencyKey = preview\.resourceRetry\.idempotencyKey/) + assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: commandSnapshot\.snapshotVersion,\s*snapshotHash: commandSnapshot\.snapshotHash,/) }) test('backend batch conversion is one transaction and never merges same-customer orders', () => { diff --git a/scripts/admin-test-env-modules/vehicle-care.mjs b/scripts/admin-test-env-modules/vehicle-care.mjs index 2e45f666a..66b48bf4e 100644 --- a/scripts/admin-test-env-modules/vehicle-care.mjs +++ b/scripts/admin-test-env-modules/vehicle-care.mjs @@ -65,7 +65,15 @@ export async function verifyVehicleCare(context, module, vehicle, proof) { incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成', details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`) check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED' - && (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED'))) + && incident.paymentStatus == null + && (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost + && incident.details.incident.currency === details.incident.currency))) + if (priced) { + const financial = await command('POST', `${base}/finance-status`, { recordNos: [incident.recordNo] }, '独立批量查询本轮处理记录付款事实') + check('处理完成未自动提交财务或标记付款', null, Array.isArray(financial) && financial.length === 1 + && financial[0].recordNo === incident.recordNo && financial[0].status === 'UNLINKED' + && financial[0].sourceVersion == null && financial[0].submittedRecordVersion == null && financial[0].finance == null) + } const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`) check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item && (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') diff --git a/scripts/admin-test-env-request-retry-contract.test.mjs b/scripts/admin-test-env-request-retry-contract.test.mjs index 8e02b0188..c26abb68d 100644 --- a/scripts/admin-test-env-request-retry-contract.test.mjs +++ b/scripts/admin-test-env-request-retry-contract.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, transportFailureDetails } from './admin-test-env-acceptance.mjs' function runContext() { return new AcceptanceRun({ gateway: 'http://acceptance.invalid', overseasGateway: 'http://overseas.invalid' }) @@ -32,14 +32,29 @@ test('mutating HTTP requests never retry an ambiguous network failure', async () let attempts = 0 globalThis.fetch = async () => { attempts += 1 - throw new TypeError('fetch failed') + throw new TypeError('fetch failed', { cause: Object.assign(new Error('private host and token'), { code: 'ECONNRESET', syscall: 'read' }) }) } try { const response = await runContext().request('POST', '/api/orders', { value: 1 }) assert.equal(attempts, 1) assert.equal(response.http, 0) assert.equal(response.raw, 'fetch failed') + assert.equal(response.transportError.causes[1].code, 'ECONNRESET') + const run = runContext(); run.options.quiet = true + run.addStep('orders', 'conflict request', response, false) + assert.deepEqual(run.steps[0].transportError, response.transportError) + assert.ok(!JSON.stringify(run.steps).includes('private host and token')) } finally { globalThis.fetch = originalFetch } }) + +test('transport diagnostics retain nested connection codes without addresses or exception messages', () => { + const cause = Object.assign(new Error('secret endpoint'), { code: 'ETIMEDOUT', address: 'private', syscall: 'connect' }) + const error = new TypeError('fetch failed', { cause: new AggregateError([cause]) }) + cause.cause = error + const result = transportFailureDetails(error) + assert.equal(result.causes.length, 3) + assert.equal(result.causes[2].code, 'ETIMEDOUT') + assert.doesNotMatch(JSON.stringify(result), /secret|private|endpoint/) +}) diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index ff1d2e60a..57e57b982 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -9,6 +9,7 @@ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $pom = Join-Path $repoRoot "pom.xml" $mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs" $requiredTests = @( + "scripts/acceptance-evidence-audit.test.mjs", "scripts/gateway-route-contract.test.mjs", "scripts/mapper-scan-contract.test.mjs", "firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java", @@ -69,6 +70,8 @@ Push-Location $repoRoot try { & node --test (Join-Path $repoRoot "scripts/gateway-route-contract.test.mjs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & node --test (Join-Path $repoRoot "scripts/acceptance-evidence-audit.test.mjs") + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & node $mapperScanContract if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & $MavenCommand @mavenArgs diff --git a/scripts/client-notifications-contract.test.mjs b/scripts/client-notifications-contract.test.mjs index 18f361df9..77c6a22a0 100644 --- a/scripts/client-notifications-contract.test.mjs +++ b/scripts/client-notifications-contract.test.mjs @@ -8,7 +8,10 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url)) const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') test('Client full acceptance executes the real notification inbox flow', () => { - assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/) + const isolation = source.indexOf('await isolationModule()') + const notifications = source.indexOf('await notificationInboxModule()') + const security = source.indexOf('await accountSecurityModule()') + assert.ok(isolation >= 0 && notifications > isolation && security > notifications) assert.match(source, /async function notificationInboxModule\(\)/) for (const permission of [ 'merchant:notification:view', diff --git a/scripts/client-phase2-measurement-evidence.mjs b/scripts/client-phase2-measurement-evidence.mjs new file mode 100644 index 000000000..adf493a2c --- /dev/null +++ b/scripts/client-phase2-measurement-evidence.mjs @@ -0,0 +1,29 @@ +const positive = value => ['number', 'string'].includes(typeof value) + && String(value).trim() !== '' && Number.isFinite(Number(value)) && Number(value) > 0 + +export function frozenOuterMeasurement(item) { + const outer = [item?.calculatedOuterLengthCm, item?.calculatedOuterWidthCm, item?.calculatedOuterHeightCm] + if (!outer.every(positive)) throw Error('测量缺少已冻结的计费外尺寸;请准备当前基线的新样本') + const volume = outer.reduce((total, value) => total * Number(value), 1) / 1_000_000 + return { outer: outer.map(Number), unitVolumeCbm: Math.round((volume + Number.EPSILON) * 1000) / 1000 } +} + +export function publishedMeasurementItemsMatch(published, platform) { + const exactId = item => typeof item?.id === 'string' && /^[1-9]\d*$/.test(item.id) + if (!Array.isArray(published) || !Array.isArray(platform) || platform.length === 0 + || !published.every(exactId) || !platform.every(exactId) || published.length !== platform.length + || new Set(platform.map(item => String(item.id))).size !== platform.length + || new Set(published.map(item => String(item.id))).size !== published.length) return false + return published.every(item => { + const source = platform.find(row => String(row.id) === String(item.id)) + if (!source) return false + const frozen = frozenOuterMeasurement(source) + const numbers = { packageCount: source.packageCount, lengthCm: frozen.outer[0], widthCm: frozen.outer[1], + heightCm: frozen.outer[2], unitVolumeCbm: source.unitVolumeCbm, + totalVolumeCbm: source.totalVolumeCbm, volumeWeightKg: source.volumeWeightKg } + return Object.entries(numbers).every(([key, expected]) => positive(item[key]) && positive(expected) + && Math.abs(Number(item[key]) - Number(expected)) <= 0.000001) + && String(item.chargeRuleId ?? '') === String(source.chargeRuleId ?? '') + && item.coefficientMode === source.coefficientMode && item.calculationRuleVersion === source.calculationRuleVersion + }) +} diff --git a/scripts/client-phase2-measurement-evidence.test.mjs b/scripts/client-phase2-measurement-evidence.test.mjs new file mode 100644 index 000000000..9fa01e236 --- /dev/null +++ b/scripts/client-phase2-measurement-evidence.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { frozenOuterMeasurement, publishedMeasurementItemsMatch } from './client-phase2-measurement-evidence.mjs' + +const source = () => ({ id: '9007199254740993', packageCount: 1, lengthCm: 49.5, widthCm: 39.5, heightCm: 38.5, + calculatedOuterLengthCm: 53.5, calculatedOuterWidthCm: 43.5, calculatedOuterHeightCm: 42.5, + unitVolumeCbm: 0.099, totalVolumeCbm: 0.099, volumeWeightKg: 16.533, + chargeRuleId: '9007199254740994', coefficientMode: 'ADD', calculationRuleVersion: 'FROZEN-1' }) +const published = () => ({ ...source(), lengthCm: 53.5, widthCm: 43.5, heightCm: 42.5 }) +test('客户计费读取已冻结外尺寸,不把原始测量或当前箱规当成发布尺寸', () => { + assert.deepEqual(frozenOuterMeasurement(source()), { outer: [53.5, 43.5, 42.5], unitVolumeCbm: 0.099 }) + assert.equal(publishedMeasurementItemsMatch([published()], [source()]), true) + assert.equal(publishedMeasurementItemsMatch([source()], [source()]), false) +}) +test('发布值、计费规则快照及精确长整数来源不一致时拒绝', () => { + for (const mutate of [v => { v.id = '9007199254740992' }, v => { v.lengthCm++ }, + v => { v.unitVolumeCbm += 0.001 }, v => { v.chargeRuleId = 'OTHER' }, + v => { v.coefficientMode = 'MULTIPLY' }, v => { v.calculationRuleVersion = 'NEW' }]) { + const item = published(); mutate(item) + assert.equal(publishedMeasurementItemsMatch([item], [source()]), false) + } + assert.equal(publishedMeasurementItemsMatch([published(), published()], [source(), source()]), false) + assert.equal(publishedMeasurementItemsMatch([], []), false) + assert.equal(publishedMeasurementItemsMatch([{ ...published(), id: null }], [{ ...source(), id: null }]), false) + assert.equal(publishedMeasurementItemsMatch([{ ...published(), id: 9007199254740993 }], [source()]), false) + assert.throws(() => frozenOuterMeasurement({ ...source(), calculatedOuterLengthCm: null })) +}) diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index 877ddad91..2aeef1644 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -14,6 +14,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' +import { frozenOuterMeasurement, publishedMeasurementItemsMatch } from './client-phase2-measurement-evidence.mjs' const args = parseArgs(process.argv.slice(2)) const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) @@ -75,7 +76,6 @@ let pendingFixture let foreignFixture let platformTerminalDetail let platformMeasurement -let dimensionRules = [] let merchantBilling try { @@ -192,7 +192,7 @@ async function fixtureDiscoveryModule() { })) platformTerminalDetail = detail.json.data - const dimensionConfig = await apiStep('phase2-measurement-fixture', + await apiStep('phase2-measurement-fixture', 'load four customer dimension weighting rules', { method: 'GET', path: `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${report.resources.tenantId}`, @@ -214,7 +214,6 @@ async function fixtureDiscoveryModule() { version: rule.version, })), })) - dimensionRules = dimensionConfig.json.data.rules const measurement = await apiStep('phase2-measurement-fixture', 'load approved terminal measurement detail', { @@ -297,9 +296,9 @@ async function fixtureDiscoveryModule() { }) await softLocalStep('phase2-measurement-fixture', - 'carton billing volume uses current customer dimension weighting', () => { + 'carton billing volume agrees with frozen calculated outer dimensions', () => { for (const item of platformMeasurement.items) { - const expected = resolveWeightedDimensions(item, dimensionRules) + const expected = frozenOuterMeasurement(item) assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, `${item.itemNo} weighted unit volume`) } @@ -310,7 +309,7 @@ async function fixtureDiscoveryModule() { measured: dimensions(item), storedOuter: dimensions(item, 'outer'), unitVolumeCbm: item.unitVolumeCbm, - expected: resolveWeightedDimensions(item, dimensionRules), + expected: frozenOuterMeasurement(item), })), }) @@ -486,22 +485,13 @@ async function terminalBillingModule() { await softLocalStep('phase2-client-billing', 'published carton measurements match platform snapshot', () => { const publishedItems = arrayData(merchantBilling.measurementItems) - assertEqual(publishedItems.length, platformMeasurement.items.length, 'published measurement item count') - const platformById = new Map(platformMeasurement.items.map((item) => [String(item.id), item])) - for (const item of publishedItems) { - assertStringId(item.id, 'published measurement item id') - const expected = platformById.get(String(item.id)) - assertPresent(expected, `platform measurement item ${item.id}`) - for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm', - 'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) { - assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`) - } - } + assertTrue(publishedMeasurementItemsMatch(publishedItems, platformMeasurement.items), 'published outer dimensions and frozen calculation metadata') }, { measurementItemCount: arrayData(merchantBilling.measurementItems).length }) - await softLocalStep('phase2-client-billing', 'published carton volumes follow current weighting rules', () => { + await softLocalStep('phase2-client-billing', 'published carton volumes follow frozen outer dimensions', () => { + const platformById = new Map(platformMeasurement.items.map(item => [String(item.id), item])) for (const item of arrayData(merchantBilling.measurementItems)) { - const expected = resolveWeightedDimensions(item, dimensionRules) + const expected = frozenOuterMeasurement(platformById.get(String(item.id))) assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, `${item.itemNo} published weighted unit volume`) } @@ -510,7 +500,7 @@ async function terminalBillingModule() { itemNo: item.itemNo, measured: dimensions(item), unitVolumeCbm: item.unitVolumeCbm, - expected: resolveWeightedDimensions(item, dimensionRules), + frozenOuter: [item.lengthCm, item.widthCm, item.heightCm], })), }) @@ -816,33 +806,6 @@ function assertDimensionRule(rule) { 0.0005, `${rule.boxType} configured volume`) } -function resolveWeightedDimensions(item, rules) { - const measured = [Number(item.lengthCm), Number(item.widthCm), Number(item.heightCm)] - .sort((left, right) => right - left) - const candidates = [...arrayData(rules)].sort((left, right) => - Number(left.innerLengthCm) * Number(left.innerWidthCm) * Number(left.innerHeightCm) - - Number(right.innerLengthCm) * Number(right.innerWidthCm) * Number(right.innerHeightCm)) - const rule = candidates.find((candidate) => { - const inner = [Number(candidate.innerLengthCm), Number(candidate.innerWidthCm), - Number(candidate.innerHeightCm)].sort((left, right) => right - left) - return measured.every((value, index) => value <= inner[index]) - }) - if (!rule) { - const rawVolume = measured.reduce((total, value) => total * value, 1) / 1_000_000 - return { boxType: null, ruleVersion: null, outer: dimensions(item), - unitVolumeCbm: round(rawVolume, 3) } - } - const edgePair = Number(rule.edgeAdditionCm) * 2 - const outer = [Number(rule.innerLengthCm) + edgePair, Number(rule.innerWidthCm) + edgePair, - Number(rule.innerHeightCm) + edgePair] - return { - boxType: rule.boxType, - ruleVersion: rule.version, - outer, - unitVolumeCbm: round(outer.reduce((total, value) => total * value, 1) / 1_000_000, 3), - } -} - function dimensions(value, prefix = '') { const field = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}` return [value?.[field('lengthCm')] ?? null, value?.[field('widthCm')] ?? null, @@ -853,11 +816,6 @@ function sum(values, field) { return arrayData(values).reduce((total, value) => total + Number(value?.[field] ?? 0), 0) } -function round(value, scale) { - const factor = 10 ** scale - return Math.round((Number(value) + Number.EPSILON) * factor) / factor -} - function statusCounts(values) { const counts = {} for (const row of arrayData(values)) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 9a30f9b82..51fd75776 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -78,7 +78,11 @@ const report = { orderIds: [], }, steps: [], - futureModules: ['measurement', 'ocr', 'mojia', 'billing'], + supplementalAcceptance: [{ + script: 'client-phase2-test-env-acceptance.mjs', + scope: ['measurement', 'ocr', 'mojia', 'billing'], + reason: '当前入库测量与计费客户能力由专项入口验收;本报告不将其计为已验证。', + }], } let merchantToken diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs index 2e43cc6de..84e775f21 100644 --- a/scripts/current-session-overseas-relabel-acceptance.mjs +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -6,7 +6,10 @@ import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' -const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +// Unified gateway is the only public entry point after the architecture cutover. +// Keep an explicit override for older local runs, but never default acceptance +// to the retired standalone overseas gateway. +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || domestic const store = process.env.CODEX_BROWSER_LOCAL_STORAGE || join(process.env.APPDATA || '', 'Codex', 'web', 'Codex', 'Default', 'Partitions', 'codex-browser-app', 'Local Storage', 'leveldb') const outputDir = resolve(process.env.OVERSEAS_CURRENT_SESSION_OUTPUT_DIR || 'reports/current-session-overseas-relabel') const adminLoginId = process.env.ADMIN_TEST_USERNAME || 'platform_admin' @@ -114,6 +117,9 @@ async function createPda(adminToken, warehouseId, onCreated) { } async function merchantSessionForTenant(adminToken, tenantId) { + // Tenant 0 is the platform scope, not a merchant tenant and cannot be impersonated. + assert(/^\d+$/.test(id(tenantId)) && BigInt(tenantId) > 0n, + `Invalid merchant tenant for impersonation: ${id(tenantId) || 'missing'}`) const ticket = await api(domestic, adminToken, 'POST', `/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` }) return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) @@ -165,9 +171,13 @@ try { try { preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') } catch { - if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId) + if (storedCustomerId && BigInt(storedCustomerId) > 0n) { + try { preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId) } catch {} + } } - assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens') + // Domestic gateway architecture issues one platform access token; an overseas + // token is only required for the PDA session created below. + assert(admin.accessToken, 'Platform login/refresh response did not contain an access token') const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run') const scope = resume ? { diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index e5cb45c1d..625f13a9a 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -121,14 +121,22 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目 commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) - assert.equal(targets.baiyue_iam.target, '3') - assert.equal(targets.baiyue_firstleg.target, '6') + assert.equal(targets.baiyue_iam.target, '4') + assert.equal(targets.baiyue_iam.migrations.at(-1).path, + 'migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql') + assert.equal(targets.baiyue_firstleg.target, '7') assert.equal(targets.baiyue_finance.target, '2') - assert.equal(targets.baiyue_lastleg.target, '7') + assert.equal(targets.baiyue_lastleg.target, '13') + assert.deepEqual(targets.baiyue_lastleg.migrations.slice(-4).map(migration => migration.path), [ + 'migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql', + 'migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql', + 'migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql', + 'migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql', + ]) assert.equal(targets.baiyue_lastlag_warehouse.target, '2') assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ - 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', + 'migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql', ]) }) diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 74105200f..8802ee090 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -4304,3 +4304,730 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; + +-- 2026-09-29: 箱维度退货出库明细不绑定托盘,托盘号必须允许为空。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号,箱维度出库为空''', 'SELECT 1')); +PREPARE outbound_box_nullable_stmt FROM @ddl; EXECUTE outbound_box_nullable_stmt; DEALLOCATE PREPARE outbound_box_nullable_stmt; + +-- 2026-09-29: 换标箱唛总文件字段,兼容旧库增量升级。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_application') AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url, ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id', 'SELECT 1')); +PREPARE relabel_carton_total_stmt FROM @ddl; EXECUTE relabel_carton_total_stmt; DEALLOCATE PREPARE relabel_carton_total_stmt; + +-- 2026-09-29 退货三类签收、合箱计划及出库预留,目标库 baiyue_lastleg。 +USE baiyue_lastleg; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收实物类型 PALLET/BOX/PARCEL'' AFTER receipt_batch_no, ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收实物编码'' AFTER unit_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''本次实物总箱数,托盘独立于SKU箱数'' AFTER unit_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''托盘长度CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''托盘宽度CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''托盘高度CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''托盘重量KG''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''本次SKU对应箱数'' AFTER received_quantity', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(16) NULL COMMENT ''来源实物类型'' AFTER source_return_no, ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源实物编码'' AFTER source_unit_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); +CREATE TABLE IF NOT EXISTS ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '退货合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '合箱计划号', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', return_nos_json JSON NOT NULL COMMENT '参与退货单号', boxes_json JSON NOT NULL COMMENT '客户下发箱明细', mode VARCHAR(32) NOT NULL COMMENT '指令模式', status VARCHAR(32) NOT NULL COMMENT '计划状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', pushed_at DATETIME(3) NULL COMMENT '推送PDA时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no), UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key), KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at) +) ENGINE=InnoDB COMMENT='客户退货合箱计划及PDA作业'; + +-- 2026-09-29: 托盘是载体,同一签收批次允许生成多个独立退货箱。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt, ADD INDEX idx_ll_return_inventory_carton_receipt(source_receipt_id)', 'SELECT 1')); +PREPARE return_carton_receipt_index_stmt FROM @ddl; EXECUTE return_carton_receipt_index_stmt; DEALLOCATE PREPARE return_carton_receipt_index_stmt; + +-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。 +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +USE baiyue_lastleg; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +CREATE TABLE IF NOT EXISTS ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细', + mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消', + remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no), + UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key), + KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at) +) ENGINE=InnoDB COMMENT='退货合箱计划'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); +CREATE TABLE IF NOT EXISTS ll_return_packing_trace ( + id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键', + operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号', + sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数', + created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id), + KEY idx_return_packing_trace_order(customer_id,return_no,created_at), + KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code), + KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code), + CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯'; +ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +-- Existing outbound tables need this field even when created before the fee snapshot baseline. +USE baiyue_lastleg; +SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照'''); +PREPARE return_outbound_stmt FROM @return_outbound_ddl; +EXECUTE return_outbound_stmt; +DEALLOCATE PREPARE return_outbound_stmt; +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement ( + id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', + outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', + outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', + total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', + available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量', + available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量', + cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code), + UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code), + KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at), + CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 + AND total_before-total_after=quantity AND available_before-available_after=quantity + AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after) +) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; + +-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。 +USE baiyue_lastleg; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='return_carton_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick ADD COLUMN return_carton_id BIGINT NULL COMMENT ''独立退货库存箱ID,与普通仓位互斥''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID'; +CREATE TABLE IF NOT EXISTS ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; +ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥'; +-- 真实退回凭据与尾程订单客户键使用同一排序规则,避免相关库存子查询冲突。 +SET @return_customer_collation = (SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id'); +SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''客户数据归属'''); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN source_outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''原发运出库业务编号'''); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_carton_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_carton_id BIGINT NULL COMMENT ''原生退货箱来源ID,与实收来源互斥''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND constraint_name='chk_dispatch_pick_receipt_source')>0, + 'ALTER TABLE ll_dispatch_pda_pick_order DROP CHECK chk_dispatch_pick_receipt_source', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +ALTER TABLE ll_dispatch_pda_pick_order ADD CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1); +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='signed_packages')=0, + 'ALTER TABLE ll_delivery_order ADD COLUMN signed_packages INT NULL COMMENT ''保存签收资料时冻结的实际签收箱数,不含此前已退回量''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +-- 补齐排车实体引用的真实退回来源字段;原生退货箱单独使用 return_carton_id。 +USE baiyue_lastleg; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_item ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +-- 退货换标排车使用正式车次域,补齐旧本地库缺少的车队结构。 +-- MySQL 8;显式 baiyue_lastleg;不写业务数据,可重复执行。 +USE baiyue_lastleg; +USE baiyue_iam; +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at) +VALUES + (5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) +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; +INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-master-data:driver:list', + 'platform:overseas-master-data:driver:create', + 'platform:overseas-master-data:driver:edit', + 'platform:overseas-master-data:driver:upload', + 'platform:overseas-master-data:driver:review', + 'platform:fleet:trip:list', + 'platform:fleet:trip:create', + 'platform:fleet:trip:assign', + 'platform:fleet:trip:edit', + 'platform:fleet:trip:dispatch-review', + 'platform:fleet:trip:loading-review') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +USE baiyue_lastleg; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_load_kg')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_load_kg DECIMAL(12,3) NULL COMMENT ''最大载重,千克''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_volume_cbm')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_volume_cbm DECIMAL(12,3) NULL COMMENT ''可用容积,立方米''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +CREATE TABLE IF NOT EXISTS ll_fleet_driver ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号', + warehouse_id BIGINT NULL COMMENT '所属公共仓库引用', + warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照', + warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照', + vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述', + languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域', + medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质', + training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质', + insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质', + name VARCHAR(128) NOT NULL COMMENT '姓名', + phone VARCHAR(64) NOT NULL COMMENT '联系电话', + ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + license_number VARCHAR(128) NULL COMMENT '驾驶证编号', + license_type VARCHAR(64) NULL COMMENT '驾驶证类型', + license_issued_on DATE NULL COMMENT '驾驶证签发日期', + license_expires_on DATE NULL COMMENT '驾驶证有效截止日', + license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区', + license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_no (driver_no), + KEY idx_fleet_driver_page (active,review_status,updated_at,id), + KEY idx_fleet_driver_expiry (license_expires_on), + CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')), + CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案'; + +CREATE TABLE IF NOT EXISTS ll_fleet_driver_history ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_id BIGINT NOT NULL COMMENT '司机内部引用', + record_version BIGINT NOT NULL COMMENT '操作后的档案版本', + action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见', + license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用', + snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version), + KEY idx_fleet_driver_history_file (driver_id,license_file_id), + CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record ( + id BIGINT NOT NULL COMMENT '记录内部主键', + record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号', + vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键', + vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照', + record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)', + details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照', + files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人', + updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_no (record_no), + UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key), + KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id), + CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record_history ( + id BIGINT NOT NULL COMMENT '历史内部主键', + record_id BIGINT NOT NULL COMMENT '运维记录主键', + record_version BIGINT NOT NULL COMMENT '对应业务版本', + action VARCHAR(32) NOT NULL COMMENT '操作类型', + reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)', + snapshot_json JSON NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_revision (record_id, record_version), + CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256', + status VARCHAR(16) NOT NULL COMMENT '车次状态', + warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用', + warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照', + warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照', + vehicle_id BIGINT NULL COMMENT '车辆档案引用', + vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照', + driver_id BIGINT NULL COMMENT '司机档案引用', + driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话', + max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', + max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', + planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', + expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + departed_at DATETIME(3) NULL COMMENT '实际发车UTC', + completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', + stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', + planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照', + loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核', + departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识', + departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要', + departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果', + completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照', + vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录', + driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_no (trip_no), + UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key), + KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id), + CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id), + CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')), + CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; + +CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer ( + id BIGINT NOT NULL COMMENT '内部主键', + offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号', + trip_id BIGINT NOT NULL COMMENT '唯一所属车次', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭', + cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间', + contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人', + contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话', + remark VARCHAR(1000) NULL COMMENT '公开承运说明', + published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_capacity_offer_no(offer_no), + UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id), + KEY idx_fleet_capacity_offer_status(status,cutoff_at,id), + CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存'; + +CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer_command ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域', + operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者', + action VARCHAR(32) NOT NULL COMMENT '业务命令类型', + scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域', + idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要', + result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放', + created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key), + CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_external_booking ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属', + customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', + customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', + status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', + destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', + destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', + agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', + idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键', + request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要', + confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键', + confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', + cancel_key VARCHAR(128) NULL COMMENT '取消请求键', + cancel_hash CHAR(64) NULL COMMENT '取消摘要', + incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', + receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', + incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', + incoming_hash CHAR(64) NULL COMMENT '来货登记摘要', + receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键', + receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', + binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', + binding_hash CHAR(64) NULL COMMENT '关联发运摘要', + offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', + business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', + delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间', + expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + KEY idx_fleet_booking_offer(offer_id,status,id), + KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), + CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), + CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_booking_no(booking_no), + UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), + KEY idx_fleet_booking_trip(trip_id,status,id), + UNIQUE KEY uk_fleet_booking_incoming(incoming_no), + KEY idx_fleet_booking_dispatch(dispatch_no), + CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pool_order_link ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_id BIGINT NOT NULL COMMENT '唯一外接预订', + booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围', + business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型', + business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联', + business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库', + destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键', + delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓', + delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明', + incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空', + lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号', + fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成', + stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间', + actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空', + actual_package_count INT NULL COMMENT '实际箱数,未知为空', + actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本', + creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要', + created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_pool_order_booking(booking_id), + UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no), + UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id), + UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no), + UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id), + KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no), + KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no), + CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id), + CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')), + CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费'; + +CREATE TABLE IF NOT EXISTS ll_fleet_resource_assignment ( + resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', + trip_id BIGINT NOT NULL COMMENT '占用车次', + trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (resource_kind,resource_id), + KEY idx_fleet_resource_trip (trip_id), + CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_dispatch ( + dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', + customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', + trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + package_count INT NOT NULL COMMENT '关联时待发运箱数快照', + bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', + bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', + review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), + KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_history ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + record_version BIGINT NOT NULL COMMENT '操作后版本', + action VARCHAR(32) NOT NULL COMMENT '车次操作', + reason VARCHAR(1000) NULL COMMENT '操作原因', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version), + CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE IF NOT EXISTS ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE IF NOT EXISTS ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE IF NOT EXISTS ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; diff --git a/scripts/db/recent-schema-incremental-20261008.sql b/scripts/db/recent-schema-incremental-20261008.sql new file mode 100644 index 000000000..0ef896203 --- /dev/null +++ b/scripts/db/recent-schema-incremental-20261008.sql @@ -0,0 +1,549 @@ +-- 2026-10-08 近期结构增量汇总(MySQL 8.0.16+) +-- 范围:2026-09-21 至 2026-10-08;用于已有业务库升级,不是空库初始化。 +-- 按 USE 显式切换 baiyue_iam / baiyue_firstleg / baiyue_lastleg / baiyue_lastlag_warehouse。 +-- 独立文件,无 SOURCE 或外部文件依赖;在目标 MySQL 连接中执行整个文件。 +-- 不创建数据库、不写业务行、不含账号或密码;DDL 隐式提交,出错立即停止,不使用 --force。 +-- 可重复执行,也支持仅部分新增字段已存在的情况。 +-- 来源:recent-schema-incremental-20260923.sql、customs-two-file-incremental-20260926.sql、 +-- return-receipt-box-count-incremental-20260929.sql、return-outbound-box-nullable-20260929.sql、 +-- return-packing-incremental-20261008.sql 以及 incremental.sql 的 2026-09-29 追加项。 + +-- 2026-09-23 近期字段与表结构统一增量(MySQL 8.0+) +-- +-- 范围:2026-09-21 至 2026-09-22 合入的结构变化。 +-- 特性:显式切换到所属 schema;字段和索引均通过 information_schema 判断;可重复执行。 +-- 边界:不创建数据库、不删除业务字段/表、不包含权限种子或业务数据修正。 +-- 注意:DDL 在 MySQL 中会隐式提交;任一步失败后应停止执行并核对目标实例。 + +SET NAMES utf8mb4; + +-- ============================================================================ +-- IAM:国内头程 PDA 操作员绑定作业仓 +-- ============================================================================ +USE baiyue_iam; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ), + 'ALTER TABLE iam_membership ADD COLUMN domestic_warehouse_id BIGINT NULL COMMENT ''国内头程PDA操作员绑定的唯一国内作业仓ID'' AFTER overseas_warehouse_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @domestic_warehouse_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @domestic_warehouse_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @ddl = CASE + WHEN NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN 'SELECT 1' + WHEN @domestic_warehouse_index_columns IS NULL THEN + 'ALTER TABLE iam_membership ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + WHEN @domestic_warehouse_index_columns <> 'tenant_id,domestic_warehouse_id,status' + OR @domestic_warehouse_index_non_unique <> 1 THEN + 'ALTER TABLE iam_membership DROP INDEX idx_membership_domestic_warehouse, ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:人工 FBA 报价允许缺少船司价格快照 +-- ============================================================================ +USE baiyue_firstleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_id' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT ''船司FBA价格ID;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_version' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT ''船司价格版本快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'rate_effective_from' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT ''价格生效开始快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流 +-- ============================================================================ + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'inbound_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'measurement_kind' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN measurement_kind VARCHAR(16) NOT NULL DEFAULT ''BOX_LABEL'' COMMENT ''测量分流:BOX_LABEL箱唛;SKU一件代发SKU'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''设备实测重量kg'' AFTER height_cm', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + AND (column_type <> 'decimal(18,3)' OR is_nullable <> 'NO' OR column_default IS NOT NULL + OR column_comment <> '设备实测重量kg') + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN weight_kg DECIMAL(18,3) NOT NULL COMMENT ''设备实测重量kg''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_status' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_status VARCHAR(24) NULL COMMENT ''SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED'' AFTER status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_code VARCHAR(64) NULL COMMENT ''SKU同步失败业务码'' AFTER sync_status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_reason' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的SKU同步失败原因'' AFTER sync_failure_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'lingxing_sku_id' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN lingxing_sku_id VARCHAR(128) NULL COMMENT ''领星SKU内部标识'' AFTER sync_failure_reason', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'revision' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN revision INT NOT NULL DEFAULT 1 COMMENT ''当前测量版本号'' AFTER lingxing_sku_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_code' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_code VARCHAR(16) NULL COMMENT ''正式箱唛业务匹配失败码''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_reason' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的箱唛匹配失败原因''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'box_code' + AND column_comment <> '设备扫描的原始箱码或不带#的SKU' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN box_code VARCHAR(128) NOT NULL COMMENT ''设备扫描的原始箱码或不带#的SKU''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'status' + AND column_comment <> '原始收件状态;SKU记录使用SKU_PENDING_SYNC' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN status VARCHAR(24) NOT NULL DEFAULT ''UNMATCHED'' COMMENT ''原始收件状态;SKU记录使用SKU_PENDING_SYNC''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Lastleg:同一尾程单允许多次换标申请,订单关联由唯一索引改为普通索引 +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order' + ), + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @relabel_order_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @relabel_order_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @ddl = CASE + WHEN @relabel_order_index_columns IS NULL THEN + 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_order_index_columns <> 'lastleg_order_id' + OR @relabel_order_index_non_unique <> 1 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX idx_ll_relabel_application_order, ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Overseas Warehouse:A0 系统共享溢出库位标记 +-- 注意:正式 schema 名沿用项目现有拼写 baiyue_lastlag_warehouse。 +-- ============================================================================ +USE baiyue_lastlag_warehouse; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + ), + 'ALTER TABLE wh_location ADD COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)'' AFTER location_type', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND (column_type <> 'tinyint(1)' OR is_nullable <> 'NO' + OR COALESCE(column_default, '') <> '0' + OR column_comment <> '是否系统共享溢出库位(A0)') + ), + 'ALTER TABLE wh_location MODIFY COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- 最终只读核对;四行均应返回 READY。 +SELECT 'baiyue_iam' AS target_schema, + IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND column_name = 'domestic_warehouse_id') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND index_name = 'idx_membership_domestic_warehouse') = 'tenant_id,domestic_warehouse_id,status', + 'READY', 'CHECK_REQUIRED' + ) AS schema_status +UNION ALL +SELECT 'baiyue_firstleg', + IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_mojia_measurement_receipt' AND column_name IN ('measurement_kind','weight_kg','sync_status','sync_failure_code','sync_failure_reason','lingxing_sku_id','revision')) = 7 + AND (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_fee' AND column_name IN ('carrier_rate_id','carrier_rate_version','rate_effective_from') AND is_nullable = 'YES') = 3, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastleg', + IF( + NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'uk_ll_relabel_application_order') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 'lastleg_order_id' + AND (SELECT MIN(non_unique) FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 1, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastlag_warehouse', + IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_lastlag_warehouse' + AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND column_type = 'tinyint(1)' + AND is_nullable = 'NO' + AND COALESCE(column_default, '') = '0' + ), + 'READY', 'CHECK_REQUIRED' + ); + + +-- 报关两文件流程:回执与真实报关完税文件分离。 +-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。 +USE baiyue_firstleg; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_by'), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID''', 'SELECT 1'); +PREPARE recent_customs_stmt FROM @ddl; EXECUTE recent_customs_stmt; DEALLOCATE PREPARE recent_customs_stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_by_name'), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称''', 'SELECT 1'); +PREPARE recent_customs_stmt FROM @ddl; EXECUTE recent_customs_stmt; DEALLOCATE PREPARE recent_customs_stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at'), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间''', 'SELECT 1'); +PREPARE recent_customs_stmt FROM @ddl; EXECUTE recent_customs_stmt; DEALLOCATE PREPARE recent_customs_stmt; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 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), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; + + +-- 退货/换标近期结构增量汇总(MySQL 8.0+) +-- +-- 目标数据库:baiyue_lastleg +-- 覆盖:退货箱级签收、箱级出库、退货合箱预占/追溯、换标箱唛总文件。 +-- 本文件只执行 DDL,不写入业务数据;所有字段、索引和约束均按元数据判断, +-- 可在同一个目标库上重复执行。数据库不存在或基线表缺失时会立即报错并停止。 + +SET NAMES utf8mb4; +USE `baiyue_lastleg`; + +-- 2026-09-29:箱维度退货出库不绑定托盘。 +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pallet_item') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pallet_item' AND column_name = 'pallet_no' AND is_nullable = 'NO'), + 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号,箱维度出库为空''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2026-09-29:退货签收单元、SKU 箱数及来源实物追溯。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'unit_type'), 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'unit_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'total_box_count'), 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收 SKU 箱数汇总''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'length'), 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸 cm''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'width'), 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸 cm''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'height'), 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸 cm''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'weight'), 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量 kg''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt_item' AND column_name = 'box_count'), 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU 签收箱数''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'source_unit_type'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''来源实物类型''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'source_unit_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源实物业务码''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2026-09-29:换标箱唛总文件。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND column_name = 'carton_total_file_id'), 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件 ID''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND column_name = 'carton_total_file_name'), 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 合箱计划及精确来源/结果快照。 +CREATE TABLE IF NOT EXISTS ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '合箱计划 ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓 ID', + return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细', + mode VARCHAR(32) NOT NULL COMMENT 'SKU 散件或 BOX 箱合并', status VARCHAR(32) NOT NULL COMMENT '计划状态', + remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_return_packing_plan_no (plan_no), + UNIQUE KEY uk_ll_return_packing_plan_key (customer_id, idempotency_key), + KEY idx_ll_return_packing_plan_warehouse (overseas_warehouse_id, status, pushed_at) +) ENGINE=InnoDB COMMENT='退货合箱计划'; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan' AND column_name = 'source_items_json'), 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和 SKU 锁定数量''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan' AND column_name = 'result_marks_json'), 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan' AND column_name = 'request_hash'), 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan' AND column_name = 'completion_hash'), 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan' AND column_name = 'result_labels_json'), 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton_item' AND column_name = 'packing_reserved_quantity'), 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'location_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'putaway_at'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_operation' AND column_name = 'result_json'), 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_return_packing_trace ( + id BIGINT NOT NULL COMMENT '合箱追溯 ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键', + operation_id BIGINT NOT NULL COMMENT '合箱操作 ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛', + target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU', quantity BIGINT NOT NULL COMMENT '本次移动件数', + created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY (id), + KEY idx_return_packing_trace_order (customer_id, return_no, created_at), + KEY idx_return_packing_trace_target (customer_id, target_carton_mark, sku_code), + KEY idx_return_packing_trace_source (customer_id, source_carton_mark, sku_code), + CONSTRAINT chk_return_packing_trace_quantity CHECK (quantity > 0) +) ENGINE=InnoDB COMMENT='退货合箱逐 SKU 原始退货单不可变追溯'; + +-- 原子替换两个 CHECK;新定义验证失败时原约束保留。 +SET @recent_drop_check_0 = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'DROP CHECK chk_ll_return_inventory_carton_quantity, ', ''); +SET @recent_drop_check_1 = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'DROP CHECK chk_ll_return_inventory_carton_status, ', ''); +SET @ddl = CONCAT('ALTER TABLE ll_return_inventory_carton ', @recent_drop_check_0, @recent_drop_check_1, 'ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN (''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN (''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0)), ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN (''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一签收批次可拆成多个退货箱:唯一约束改为普通索引。 +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND index_name = 'uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND index_name = 'idx_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton ADD INDEX idx_ll_return_inventory_carton_receipt (source_receipt_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT DATABASE() AS target_database, + IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name IN ('unit_type','unit_code','total_box_count','length','width','height','weight')) = 7, 'READY', 'CHECK_REQUIRED') AS receipt_schema, + IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_plan') AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_return_packing_trace'), 'READY', 'CHECK_REQUIRED') AS packing_schema, + IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND index_name = 'idx_ll_return_inventory_carton_receipt'), 'READY', 'CHECK_REQUIRED') AS carton_index; diff --git a/scripts/db/return-outbound-box-nullable-20260929.sql b/scripts/db/return-outbound-box-nullable-20260929.sql new file mode 100644 index 000000000..01ab9e871 --- /dev/null +++ b/scripts/db/return-outbound-box-nullable-20260929.sql @@ -0,0 +1,15 @@ +-- MySQL 8.0+; target database: baiyue_lastleg. +-- Re-runnable: allow pallet-less carton outbound records. +SET NAMES utf8mb4; +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' + AND column_name='pallet_no' AND is_nullable='NO' +), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号,箱维度出库为空''', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT DATABASE() AS target_database, table_name, column_name, is_nullable +FROM information_schema.columns +WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='pallet_no'; diff --git a/scripts/db/return-packing-incremental-20261008.sql b/scripts/db/return-packing-incremental-20261008.sql new file mode 100644 index 000000000..6e7313a07 --- /dev/null +++ b/scripts/db/return-packing-incremental-20261008.sql @@ -0,0 +1,210 @@ +-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。 +SET NAMES utf8mb4; +-- IAM:平台退货合箱写操作需要正式库存管理权限;仅授予 PLATFORM_ADMIN。 +USE baiyue_iam; +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at) +VALUES (5107044, 0, 'PLATFORM', 'BUTTON', 5107040, 'lastleg:inventory:manage', '后台退货库存管理', NULL, 'POST', 744, 'ACTIVE', 1, 0, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) +FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.id=5107044 +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +USE baiyue_lastleg; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +CREATE TABLE IF NOT EXISTS ll_return_packing_plan ( + id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细', + mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消', + remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no), + UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key), + KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at) +) ENGINE=InnoDB COMMENT='退货合箱计划'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); +CREATE TABLE IF NOT EXISTS ll_return_packing_trace ( + id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键', + operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号', + sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数', + created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id), + KEY idx_return_packing_trace_order(customer_id,return_no,created_at), + KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code), + KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code), + CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯'; +ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码'; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照''')); +PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt; +-- Existing outbound tables need this field even when created before the fee snapshot baseline. +USE baiyue_lastleg; +SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照'''); +PREPARE return_outbound_stmt FROM @return_outbound_ddl; +EXECUTE return_outbound_stmt; +DEALLOCATE PREPARE return_outbound_stmt; +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement ( + id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', + outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', + outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', + total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', + available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量', + available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量', + cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code), + UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code), + KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at), + CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 + AND total_before-total_after=quantity AND available_before-available_after=quantity + AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after) +) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; + +-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。 +USE baiyue_lastleg; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='return_carton_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick ADD COLUMN return_carton_id BIGINT NULL COMMENT ''独立退货库存箱ID,与普通仓位互斥''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID'; +CREATE TABLE IF NOT EXISTS ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; +ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥'; +-- 真实退回凭据与尾程订单客户键使用同一排序规则,避免相关库存子查询冲突。 +SET @return_customer_collation = (SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id'); +SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''客户数据归属'''); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN source_outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''原发运出库业务编号'''); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_carton_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_carton_id BIGINT NULL COMMENT ''原生退货箱来源ID,与实收来源互斥''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND constraint_name='chk_dispatch_pick_receipt_source')>0, + 'ALTER TABLE ll_dispatch_pda_pick_order DROP CHECK chk_dispatch_pick_receipt_source', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +ALTER TABLE ll_dispatch_pda_pick_order ADD CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1); +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='signed_packages')=0, + 'ALTER TABLE ll_delivery_order ADD COLUMN signed_packages INT NULL COMMENT ''保存签收资料时冻结的实际签收箱数,不含此前已退回量''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +-- 补齐排车实体引用的真实退回来源字段;原生退货箱单独使用 return_carton_id。 +USE baiyue_lastleg; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_item ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; +SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='return_receipt_id')=0, + 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1'); +PREPARE return_dispatch_stmt FROM @return_dispatch_ddl; +EXECUTE return_dispatch_stmt; +DEALLOCATE PREPARE return_dispatch_stmt; diff --git a/scripts/db/return-receipt-box-count-incremental-20260929.sql b/scripts/db/return-receipt-box-count-incremental-20260929.sql new file mode 100644 index 000000000..e7497d879 --- /dev/null +++ b/scripts/db/return-receipt-box-count-incremental-20260929.sql @@ -0,0 +1,26 @@ +-- 2026-09-29 海外退货签收 SKU 箱数增量 +-- MySQL 8.0+;目标数据库:baiyue_lastleg +-- 可重复执行,不修改既有业务数据。 + +SET NAMES utf8mb4; +USE baiyue_lastleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt_item' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_return_receipt_item' + AND column_name = 'box_count' + ), + 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''本次 SKU 对应箱数'' AFTER received_quantity', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT DATABASE() AS target_database, 'll_return_receipt_item.box_count' AS updated_column; diff --git a/scripts/db/return-relabel-truck-incremental-20261008.sql b/scripts/db/return-relabel-truck-incremental-20261008.sql new file mode 100644 index 000000000..d713b6bfc --- /dev/null +++ b/scripts/db/return-relabel-truck-incremental-20261008.sql @@ -0,0 +1,493 @@ +-- 退货换标排车使用正式车次域,补齐旧本地库缺少的车队结构。 +-- MySQL 8;显式 baiyue_lastleg;不写业务数据,可重复执行。 +-- IAM:退货换标真实排车需要平台管理员的正式司机/车队权限;不授予其他角色。 +USE baiyue_iam; +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at) +VALUES + (5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)), + (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) +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; +INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-master-data:driver:list', + 'platform:overseas-master-data:driver:create', + 'platform:overseas-master-data:driver:edit', + 'platform:overseas-master-data:driver:upload', + 'platform:overseas-master-data:driver:review', + 'platform:fleet:trip:list', + 'platform:fleet:trip:create', + 'platform:fleet:trip:assign', + 'platform:fleet:trip:edit', + 'platform:fleet:trip:dispatch-review', + 'platform:fleet:trip:loading-review') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +USE baiyue_lastleg; +USE baiyue_lastleg; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_load_kg')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_load_kg DECIMAL(12,3) NULL COMMENT ''最大载重,千克''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_volume_cbm')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_volume_cbm DECIMAL(12,3) NULL COMMENT ''可用容积,立方米''', 'SELECT 1'); +PREPARE return_fleet_stmt FROM @return_fleet_ddl; +EXECUTE return_fleet_stmt; +DEALLOCATE PREPARE return_fleet_stmt; +CREATE TABLE IF NOT EXISTS ll_fleet_driver ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号', + warehouse_id BIGINT NULL COMMENT '所属公共仓库引用', + warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照', + warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照', + vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述', + languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域', + medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质', + training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质', + insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质', + name VARCHAR(128) NOT NULL COMMENT '姓名', + phone VARCHAR(64) NOT NULL COMMENT '联系电话', + ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + license_number VARCHAR(128) NULL COMMENT '驾驶证编号', + license_type VARCHAR(64) NULL COMMENT '驾驶证类型', + license_issued_on DATE NULL COMMENT '驾驶证签发日期', + license_expires_on DATE NULL COMMENT '驾驶证有效截止日', + license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区', + license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_no (driver_no), + KEY idx_fleet_driver_page (active,review_status,updated_at,id), + KEY idx_fleet_driver_expiry (license_expires_on), + CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')), + CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案'; + +CREATE TABLE IF NOT EXISTS ll_fleet_driver_history ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_id BIGINT NOT NULL COMMENT '司机内部引用', + record_version BIGINT NOT NULL COMMENT '操作后的档案版本', + action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见', + license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用', + snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version), + KEY idx_fleet_driver_history_file (driver_id,license_file_id), + CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record ( + id BIGINT NOT NULL COMMENT '记录内部主键', + record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号', + vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键', + vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照', + record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)', + details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照', + files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人', + updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_no (record_no), + UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key), + KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id), + CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record_history ( + id BIGINT NOT NULL COMMENT '历史内部主键', + record_id BIGINT NOT NULL COMMENT '运维记录主键', + record_version BIGINT NOT NULL COMMENT '对应业务版本', + action VARCHAR(32) NOT NULL COMMENT '操作类型', + reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)', + snapshot_json JSON NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_revision (record_id, record_version), + CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256', + status VARCHAR(16) NOT NULL COMMENT '车次状态', + warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用', + warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照', + warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照', + vehicle_id BIGINT NULL COMMENT '车辆档案引用', + vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照', + driver_id BIGINT NULL COMMENT '司机档案引用', + driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话', + max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', + max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', + planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', + expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + departed_at DATETIME(3) NULL COMMENT '实际发车UTC', + completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', + stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', + planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照', + loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核', + departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识', + departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要', + departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果', + completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照', + vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录', + driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_no (trip_no), + UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key), + KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id), + CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id), + CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')), + CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; + +CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer ( + id BIGINT NOT NULL COMMENT '内部主键', + offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号', + trip_id BIGINT NOT NULL COMMENT '唯一所属车次', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭', + cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间', + contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人', + contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话', + remark VARCHAR(1000) NULL COMMENT '公开承运说明', + published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_capacity_offer_no(offer_no), + UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id), + KEY idx_fleet_capacity_offer_status(status,cutoff_at,id), + CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存'; + +CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer_command ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域', + operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者', + action VARCHAR(32) NOT NULL COMMENT '业务命令类型', + scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域', + idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要', + result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放', + created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key), + CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_external_booking ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属', + customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', + customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', + status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', + destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', + destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', + agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', + idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键', + request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要', + confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键', + confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', + cancel_key VARCHAR(128) NULL COMMENT '取消请求键', + cancel_hash CHAR(64) NULL COMMENT '取消摘要', + incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', + receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', + incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', + incoming_hash CHAR(64) NULL COMMENT '来货登记摘要', + receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键', + receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', + binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', + binding_hash CHAR(64) NULL COMMENT '关联发运摘要', + offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', + business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', + delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间', + expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + KEY idx_fleet_booking_offer(offer_id,status,id), + KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), + CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), + CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_booking_no(booking_no), + UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), + KEY idx_fleet_booking_trip(trip_id,status,id), + UNIQUE KEY uk_fleet_booking_incoming(incoming_no), + KEY idx_fleet_booking_dispatch(dispatch_no), + CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pool_order_link ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_id BIGINT NOT NULL COMMENT '唯一外接预订', + booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围', + business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型', + business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联', + business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库', + destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键', + delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓', + delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明', + incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空', + lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号', + fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成', + stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间', + actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空', + actual_package_count INT NULL COMMENT '实际箱数,未知为空', + actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本', + creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要', + created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_pool_order_booking(booking_id), + UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no), + UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id), + UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no), + UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id), + KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no), + KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no), + CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id), + CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')), + CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费'; + +CREATE TABLE IF NOT EXISTS ll_fleet_resource_assignment ( + resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', + trip_id BIGINT NOT NULL COMMENT '占用车次', + trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (resource_kind,resource_id), + KEY idx_fleet_resource_trip (trip_id), + CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_dispatch ( + dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', + customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', + trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + package_count INT NOT NULL COMMENT '关联时待发运箱数快照', + bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', + bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', + review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), + KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_history ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + record_version BIGINT NOT NULL COMMENT '操作后版本', + action VARCHAR(32) NOT NULL COMMENT '车次操作', + reason VARCHAR(1000) NULL COMMENT '操作原因', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version), + CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE IF NOT EXISTS ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE IF NOT EXISTS ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE IF NOT EXISTS ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; diff --git a/scripts/fba-remaining-nodes-acceptance.mjs b/scripts/fba-remaining-nodes-acceptance.mjs index 083b26dde..e795c487f 100644 --- a/scripts/fba-remaining-nodes-acceptance.mjs +++ b/scripts/fba-remaining-nodes-acceptance.mjs @@ -10,6 +10,12 @@ export function selectSteps(report, predicate) { export function requirePassed(steps) { return steps.length > 0 && steps.every(s => s.passed === true) } +export function discardCoverageBoundary() { + return { node: 'F33-discard', + conclusion: 'Historical rejection-release evidence is retained. Current approval keeps reservations; actual PDA confirmation deducts the confirmed quantity. Approval alone does not prove completion.', + category: '需真实操作', + next: 'Prepare a fresh owned approved application and bound PDA actor; freeze carton/SKU/ledger evidence. Verify actual confirmation, final-pallet response recovery, identical replay with zero new writes, changed quantity rejection, and warehouse confirmation separately. Do not deduct stock at approval or borrow unrelated completed samples.' } +} export function transferConclusion(detail) { const shipped = Boolean(detail.actualShipAt) const terminal = detail.status === 'RECEIVED' @@ -56,8 +62,7 @@ export async function auditRemainingNodes({ root, output, env, live = false }) { evidence: [evidence(files[1], s => (s.request?.path || '').includes('WT-WH-20261002-430594'))], originalFixture: full.createdResources.phase4OverseasClientServices, next: 'Continue this exact original transfer only after current source stock, holds, destination and allocations are frozen. Verify allocation, actual outbound and target receipt with per-position balances/unique movements and same-command replay. Incoming transfer fixtures are separate sources and cannot replace this ticket.' }) - result.nodes.push({ node: 'F33-discard', conclusion: 'Rejection release supported; approval/decrement decision pending', - category: '缺用户输入', evidenceFiles: [files[5]], next: 'Await approval-time versus PDA actual-quantity decrement decision; no approval or decrement issued.' }) + result.nodes.push({ ...discardCoverageBoundary(), evidenceFiles: [files[5]] }) result.nodes.push({ node: 'F26', conclusion: 'Existing 68-check source/transit scope is already evidenced; no repeat run', category: '可立即关闭指定分支', evidenceFiles: [files[4]], boundary: 'Only 11 FBA sources/13 actual cartons and pre-receipt mapping; not all event faults or UI.' }) result.nodes.push({ node: 'F24-external', conclusion: 'External carrier lifecycle lacks valid input', diff --git a/scripts/fba-remaining-nodes-acceptance.test.mjs b/scripts/fba-remaining-nodes-acceptance.test.mjs index 4fbffa3c1..56cd81b15 100644 --- a/scripts/fba-remaining-nodes-acceptance.test.mjs +++ b/scripts/fba-remaining-nodes-acceptance.test.mjs @@ -1,6 +1,16 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { requirePassed, selectSteps, transferConclusion } from './fba-remaining-nodes-acceptance.mjs' +import { requirePassed, selectSteps, transferConclusion, discardCoverageBoundary } from './fba-remaining-nodes-acceptance.mjs' + +test('discard audit follows approval reservation and PDA actual confirmation without claiming closure', () => { + const boundary = discardCoverageBoundary() + assert.equal(boundary.category, '需真实操作') + assert.match(boundary.conclusion, /approval keeps reservations/) + assert.match(boundary.conclusion, /PDA confirmation deducts/) + assert.match(boundary.next, /identical replay with zero new writes/) + assert.match(boundary.next, /changed quantity rejection/) + assert.doesNotMatch(boundary.conclusion, /decision pending/) +}) test('missing and partially failed evidence cannot close a branch', () => { assert.equal(requirePassed([]), false) diff --git a/scripts/fleet-capacity-pool-gray.mjs b/scripts/fleet-capacity-pool-gray.mjs new file mode 100644 index 000000000..337db1683 --- /dev/null +++ b/scripts/fleet-capacity-pool-gray.mjs @@ -0,0 +1,46 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { runPoolAcceptance, poolPlan } from './fleet-capacity-pool-test-env.mjs' +import { runFleetPoolGrayCrossCustomer } from './fleet-cross-customer-test-env.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +export function parseGrayPoolArguments(args) { + const config = { execute: false, environment: 'GRAY', runId: 'plan', warehouseId: '', destinationWarehouseId: '' } + const seen = new Set() + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (['--execute', '--plan'].includes(name)) { + if (seen.has('action')) throw Error('Duplicate execution mode') + seen.add('action'); config.execute = name === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--warehouse-id': 'warehouseId', + '--destination-warehouse-id': 'destinationWarehouseId' }[name] + if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or duplicate gray pool argument') + seen.add(name); config[field] = args[++index] + } + if (config.environment !== 'GRAY' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || [config.warehouseId, config.destinationWarehouseId].some(value => value && !/^[1-9][0-9]{0,19}$/.test(value)) + || config.execute && (!seen.has('--environment') || config.runId === 'plan' || !config.warehouseId || !config.destinationWarehouseId)) + throw Error('Explicit GRAY, fresh run-id and warehouse/destination IDs required') + return config +} + +export async function runGrayPoolAcceptance(config, { env = process.env, outputRoot = ROOT, execute = runPoolAcceptance } = {}) { + if (config.environment !== 'GRAY') throw Error('Dedicated GRAY pool configuration required') + if (!config.execute) return { ...poolPlan(config), scope: 'FLEET_CAPACITY_POOL_GRAY', fullAcceptance: false, + evidence: 'createGrayEvidenceReaders: registered production SSH target, immutable container/profile/database identity checks; no TEST agent', + excluded: ['old FBA order/container/customs flow', 'generic Admin module runner', 'fault injection', 'deployment', 'business SQL writes'] } + if (!['prod', 'gray'].includes(env.CHONGCHONG_ENV)) throw Error('Dedicated GRAY pool requires actual gray or prod runtime profile; TEST is refused') + return execute(config, { env, outputRoot, runner: runFleetPoolGrayCrossCustomer }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseGrayPoolArguments(process.argv.slice(2)), result = await runGrayPoolAcceptance(config) + console.log(JSON.stringify(config.execute ? { success: result.success, environment: 'GRAY', runtimeProfile: result.runtimeProfile, + error: result.error, summary: result.summary, fullAcceptance: false, output: poolPlan(config).output, + covered: result.createdResources?.fleetCapacityPool?.covered, remaining: result.remainingSeparateAcceptance } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/fleet-capacity-pool-gray.test.mjs b/scripts/fleet-capacity-pool-gray.test.mjs new file mode 100644 index 000000000..58c3ea57a --- /dev/null +++ b/scripts/fleet-capacity-pool-gray.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, resolve, basename } from 'node:path' +import { parseGrayPoolArguments, runGrayPoolAcceptance } from './fleet-capacity-pool-gray.mjs' +import { parseCrossCustomerArguments, runCrossCustomer, runFleetPoolGrayCrossCustomer } from './fleet-cross-customer-test-env.mjs' +import { parsePoolArguments, runPoolAcceptance, poolReportName, POOL_COVERAGE } from './fleet-capacity-pool-test-env.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' + +const args = ['--execute', '--environment', 'GRAY', '--run-id', 'pool-gray-offline', '--warehouse-id', '900', '--destination-warehouse-id', '901'] +const env = { CHONGCHONG_ENV: 'prod', CHONGCHONG_ADMIN_API_BASE_URL: 'https://gray.example.invalid/api', + CHONGCHONG_ADMIN_USERNAME: 'offline-user', CHONGCHONG_ADMIN_PASSWORD: 'offline-secret' } +const transport = { gateway: 'https://gray.example.invalid', username: 'offline-user', password: 'offline-secret', + output: 'reports/fleet-pool-gray-offline', markerNamespace: 'gray-offline' } + +test('GRAY plan is zero-network and preserves exact existing pool coverage without F40', async () => { + const original = globalThis.fetch; globalThis.fetch = () => assert.fail('network forbidden') + try { + for (const argv of [[], ['--plan']]) { + const plan = await runGrayPoolAcceptance(parseGrayPoolArguments(argv), { env: {}, execute: () => assert.fail('execution forbidden') }) + assert.equal(plan.environment, 'GRAY'); assert.equal(plan.execute, false) + assert.equal(plan.output, 'reports/fleet-pool-gray-plan') + assert.deepEqual(plan.coveredByThisEntry, POOL_COVERAGE) + assert.equal(plan.fullAcceptance, false) + assert.ok(plan.excluded.includes('old FBA order/container/customs flow')) + } + } finally { globalThis.fetch = original } +}) + +test('GRAY CLI requires explicit environment fresh run and exact IDs and rejects all modules/fault switches', () => { + assert.equal(parseGrayPoolArguments(args).execute, true) + for (const bad of [['--execute'], args.filter((_, index) => index !== 1 && index !== 2), + args.map(value => value === 'GRAY' ? 'TEST' : value), args.map(value => value === 'pool-gray-offline' ? '../escape' : value), + [...args, '--modules', 'phase5-root-fulfillment'], [...args, '--verify-unload-event-recovery', 'true'], + [...args, '--readers', 'test'], [...args, '--plan'], [...args, '--run-id', 'duplicate']]) { + assert.throws(() => parseGrayPoolArguments(bad)) + } +}) + +test('existing TEST CLI and runner still refuse GRAY and production process profile', async () => { + assert.throws(() => parseCrossCustomerArguments(args), /TEST/) + assert.throws(() => parsePoolArguments(args), /TEST/) + const config = parseGrayPoolArguments(args) + await assert.rejects(runCrossCustomer(config, { env }), /TEST configuration/) + await assert.rejects(runPoolAcceptance(config, { env }), /TEST configuration/) + await assert.rejects(runGrayPoolAcceptance(config, { env: { ...env, CHONGCHONG_ENV: 'test' } }), /TEST is refused/) +}) + +test('dedicated constructor factory is branded frozen gray and carries no generic modules', () => { + const options = createFleetPoolGrayOptions(transport), context = new AcceptanceRun(options) + assert.equal(context.options.environment, 'gray'); assert.match(context.marker, /^GRAY-API-/) + assert.equal(options.executionScope, 'FLEET_CAPACITY_POOL_GRAY') + assert.deepEqual(options.modules, []); assert.deepEqual(options.requestedModules, []) + assert.equal(options.verifyUnloadEventRecovery, false) + assert.throws(() => options.modules.push('phase5-root-fulfillment'), TypeError) + assert.throws(() => new AcceptanceRun({ ...options }), /gray 仅支持/) + assert.throws(() => createFleetPoolGrayOptions({ ...transport, modules: ['phase5-root-fulfillment'] }), /configuration/) + assert.throws(() => createFleetPoolGrayOptions({ ...transport, verifyUnloadEventRecovery: true }), /configuration/) + assert.throws(() => createFleetPoolGrayOptions({ ...transport, gateway: 'http://gray.example.invalid' }), /HTTPS/) + assert.throws(() => createFleetPoolGrayOptions({ ...transport, gateway: 'https://gray.example.invalid/api' }), /HTTPS/) +}) + +test('ordinary Admin gray CLI and string-forged scope cannot opt into dedicated empty modules', () => { + assert.throws(() => validateExecutionOptions({ environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY', + requestedModules: [], modules: [], waitSplitCount: 1 }), /gray 仅支持/) + assert.throws(() => parseArguments(['--environment', 'gray']), /显式/) + assert.throws(() => validateExecutionOptions(parseArguments(['--environment', 'gray', '--modules', 'merchant-qualification-fixture', + '--execution-scope', 'FLEET_CAPACITY_POOL_GRAY'])), /gray 仅支持/) +}) + +test('GRAY delegates unchanged pool config and preserves prod profile using only dedicated runner', async () => { + const config = parseGrayPoolArguments(args), original = { ...env } + const result = await runGrayPoolAcceptance(config, { env, execute: async (actual, dependencies) => { + assert.equal(actual, config); assert.equal(actual.environment, 'GRAY') + assert.equal(dependencies.env, env); assert.equal(dependencies.env.CHONGCHONG_ENV, 'prod') + assert.equal(dependencies.runner, runFleetPoolGrayCrossCustomer) + return { scope: 'FLEET_CAPACITY_POOL_GRAY', success: true } + } }) + assert.equal(result.success, true); assert.deepEqual(env, original) + assert.equal(poolReportName(config), 'fleet-pool-gray-pool-gray-offline') + assert.notEqual(poolReportName(config), poolReportName({ ...config, environment: 'TEST' })) +}) + +test('GRAY rejects injected TEST evidence and alternate fixture or cleanup even before filesystem writes', async () => { + const config = parseGrayPoolArguments(args), dependencies = { env, verify: async () => {}, contextFactory: () => {} } + for (const field of ['readers', 'prepare', 'finish', 'pickerAccess', 'pickerCreate', 'pickerDisable']) { + await assert.rejects(runFleetPoolGrayCrossCustomer(config, { ...dependencies, [field]: {} }), /cannot override/) + } + await assert.rejects(runFleetPoolGrayCrossCustomer(config, { env }), /dedicated scenario/) + await assert.rejects(runFleetPoolGrayCrossCustomer(config, dependencies), /Missing gray evidence configuration/) + await assert.rejects(runFleetPoolGrayCrossCustomer(config, { ...dependencies, + env: { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'http://gray.example.invalid/api' } }), /Invalid GRAY gateway/) +}) + +test('real gray reader is selected and failed production preflight blocks every business request', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-gray-contract-')), original = globalThis.fetch, calls = [] + globalThis.fetch = async (url, init) => { + calls.push(String(url)) + assert.equal(String(url), 'https://jenkins.example.invalid/scriptText') + assert.match(init.body.get('script'), /firstleg-production-ssh/) + assert.doesNotMatch(init.body.get('script'), /jenkins-test-agent/) + return { ok: false, status: 403 } + } + try { + const report = await runFleetPoolGrayCrossCustomer(parseGrayPoolArguments(args), { env: { ...env, + JENKINS_URL: 'https://jenkins.example.invalid', JENKINS_USER: 'offline-jenkins', JENKINS_API_TOKEN: 'offline-token' }, outputRoot: root, + verify: () => assert.fail('no business verification before preflight'), contextFactory: () => assert.fail('no business context before preflight') }) + assert.equal(report.success, false); assert.match(report.error, /Gray evidence HTTP 403/) + assert.equal(report.environment, 'GRAY'); assert.equal(report.runtimeProfile, 'prod'); assert.equal(calls.length, 1) + assert.deepEqual(readdirSync(join(root, 'reports')), ['fleet-pool-gray-pool-gray-offline']) + const manifest = JSON.parse(readFileSync(join(root, 'reports/fleet-pool-gray-pool-gray-offline/manifest.json'), 'utf8')) + assert.ok(manifest.sources.some(source => source.path === 'scripts/gray-regression-evidence.mjs')) + assert.equal(manifest.sources.some(source => source.path === 'scripts/fba-core-regression-evidence.mjs'), false) + } finally { + globalThis.fetch = original + assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-gray-contract-')) + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 367c3ff8a..43cdd47a9 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -42,12 +42,18 @@ export const POOL_REMAINING = Object.freeze([ ]) export function parsePoolArguments(args) { return parseCrossCustomerArguments(args) } +export function poolReportName(config) { + requireThat(['TEST', 'GRAY'].includes(config.environment) && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'EXPLICIT_POOL_REPORT_SCOPE_REQUIRED') + return `${config.environment === 'GRAY' ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}` +} export function poolPlan(config) { - return { environment: 'TEST', execute: Boolean(config.execute), runId: config.runId, - output: `reports/fleet-cross-${config.runId}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING, + return { environment: config.environment || 'TEST', execute: Boolean(config.execute), runId: config.runId, + output: `reports/${poolReportName({ ...config, environment: config.environment || 'TEST' })}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING, sample: 'Two fresh qualified customers and owned one-box pallets, one 30-space trip. Each pool business type has one safely cancelled pre-incoming sample and one fulfilled order with one real incoming/pallet. Planning uses 18 spaces; actual physical load is separately confirmed.', - prerequisites: ['explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs', - 'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'], + prerequisites: [config.environment === 'GRAY' ? 'explicit GRAY CLI with CHONGCHONG_ENV=gray|prod and CHONGCHONG_ADMIN_*' + : 'explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs', + config.environment === 'GRAY' ? 'GRAY read-only evidence via JENKINS_* and verified production container/database identity' + : 'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'], boundary: 'Default plan performs no network or filesystem writes. Execute retains every sample and original command, blocks all further writes on an unknown outcome, never deploys or writes business SQL. No automatic rerun or resume.' } } @@ -613,7 +619,7 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot } return response } - const directory = join(outputRoot, 'reports', `fleet-cross-${config.runId}`, 'pool-write-journal') + const directory = join(outputRoot, 'reports', poolReportName(config), 'pool-write-journal') mkdirSync(directory) restoreWrites = guardFleetWrites(context, async entry => { const file = openSync(join(directory, `${entry.sequence}.json`), 'w') @@ -622,10 +628,10 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot return context }, verify: (context, fixture, options) => poolScenario(context, fixture, { ...options, credentials }) }) - result.scope = 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false + result.scope = config.environment === 'GRAY' ? 'FLEET_CAPACITY_POOL_GRAY' : 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false result.remainingSeparateAcceptance = [...POOL_REMAINING] - const output = join(outputRoot, 'reports', `fleet-cross-${config.runId}`) - const sources = ['scripts/fleet-capacity-pool-test-env.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') })) + const output = join(outputRoot, 'reports', poolReportName(config)) + const sources = ['scripts/fleet-capacity-pool-test-env.mjs', ...(config.environment === 'GRAY' ? ['scripts/fleet-capacity-pool-gray.mjs'] : [])].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') })) writeFileSync(join(output, 'pool-scope.json'), JSON.stringify({ plan: poolPlan(config), sources, success: result.success, fullAcceptance: false, covered: result.createdResources?.fleetCapacityPool?.covered || [], remaining: POOL_REMAINING }, null, 2) + '\n', { flag: 'wx' }) return result diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.mjs new file mode 100644 index 000000000..cb3a398d0 --- /dev/null +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.mjs @@ -0,0 +1,26 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseGrayPoolArguments } from './fleet-capacity-pool-gray.mjs' +import { privateCredentialsPath, runGrayUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs' + +export function parseGrayUiFixtureArguments(args) { + const ordinary = []; let privateFile + for (let index = 0; index < args.length; index++) { + if (args[index] !== '--private-credentials-file') { ordinary.push(args[index]); continue } + if (privateFile !== undefined || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('One explicit private credential file required') + privateFile = privateCredentialsPath(args[++index]) + } + const config = parseGrayPoolArguments(ordinary) + if (config.execute && !privateFile) throw Error('Exclusive private credentials file required for GRAY UI execution') + return { ...config, privateCredentialsFile: privateFile || null } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseGrayUiFixtureArguments(process.argv.slice(2)), result = await runGrayUiFixture(config) + console.log(JSON.stringify(config.execute ? { success: result.success, fixtureReady: result.fixtureReady, environment: result.environment, + runtimeProfile: result.runtimeProfile, fullAcceptance: false, error: result.error, summary: result.summary, + retainedPda: result.retainedPda, output: result.output, privateCredentialsFile: result.privateCredentialsFile } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch { console.error('GRAY UI fixture preparation failed; preserve existing private credentials and report for review.'); process.exitCode = 1 } +} diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs new file mode 100644 index 000000000..9c8720cfa --- /dev/null +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { parseGrayUiFixtureArguments } from './fleet-capacity-pool-ui-fixture-gray.mjs' +import { PRIVATE_DIRECTORY, runGrayUiFixture, runUiFixture, uiActorPermissions, grayUiSchedule, + applyGrayUiTransportWindow, uiInquiryBody } from './fleet-capacity-pool-ui-fixture-test-env.mjs' + +const args = ['--execute', '--environment', 'GRAY', '--run-id', 'ui-gray-offline', '--warehouse-id', '900', + '--destination-warehouse-id', '901', '--private-credentials-file', join(PRIVATE_DIRECTORY, 'ui-gray-offline.json')] +const env = { CHONGCHONG_ENV: 'prod', CHONGCHONG_ADMIN_API_BASE_URL: 'https://gray.example.invalid/api', + CHONGCHONG_ADMIN_USERNAME: 'offline', CHONGCHONG_ADMIN_PASSWORD: 'offline-only' } + +test('GRAY UI default plan does not access network or private credential files and keeps only four drafts', async () => { + const original = globalThis.fetch; globalThis.fetch = () => assert.fail('no network') + try { + const plan = await runGrayUiFixture(parseGrayUiFixtureArguments([]), { env: {} }) + assert.equal(plan.execute, false); assert.equal(plan.environment, 'GRAY'); assert.equal(plan.fullAcceptance, false) + assert.equal(plan.stopsAt, 'OPEN_AND_4_DRAFTS_READY_FOR_UI'); assert.equal(plan.retainedPda, true) + assert.equal(plan.output, 'reports/fleet-capacity-pool-ui-gray-plan') + assert.equal(plan.privateCredentialsFile, null) + assert.match(plan.boundary, /all six UI logins/) + } finally { globalThis.fetch = original } +}) + +test('GRAY UI CLI requires explicit scope and private exclusive file without arbitrary lifecycle switches', () => { + assert.equal(parseGrayUiFixtureArguments(args).environment, 'GRAY') + for (const bad of [args.slice(0, -2), args.map(value => value === 'GRAY' ? 'TEST' : value), + [...args, '--private-credentials-file', args.at(-1)], [...args, '--modules', 'orders'], + [...args, '--retain-pda', 'false'], [...args, '--draft-count', '24']]) assert.throws(() => parseGrayUiFixtureArguments(bad)) +}) + +test('GRAY UI refuses TEST profile or evidence overrides and TEST runner rejects GRAY', async () => { + const config = parseGrayUiFixtureArguments(args) + await assert.rejects(runUiFixture(config, { env }), /TEST/) + await assert.rejects(runGrayUiFixture(config, { env: { ...env, CHONGCHONG_ENV: 'test' } }), /GRAY_FIXTURE_CONFIGURATION/) + for (const name of ['readers', 'prepare', 'contextFactory', 'pickerDisable']) { + await assert.rejects(runGrayUiFixture(config, { env, [name]: {} }), /CANNOT_OVERRIDE/) + } + await assert.rejects(runGrayUiFixture(config, { env }), /Missing gray evidence configuration/) + await assert.rejects(runGrayUiFixture(config, { env: { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'http://gray.example.invalid/api' } }), /INVALID_UI_API_BASE/) +}) + +test('only sales A receives quotation-read while dispatcher and sales B remain minimal', () => { + const prefix = 'platform:fleet:capacity-offer:' + const sales = ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(value => prefix + value) + assert.deepEqual(uiActorPermissions('SALES_A', true), [...sales, 'platform:fleet:quote:list']) + assert.deepEqual(uiActorPermissions('SALES_B', true), sales) + assert.deepEqual(uiActorPermissions('MANAGER', true), [prefix + 'manage']) + assert.deepEqual(uiActorPermissions('SALES_A', false), sales) +}) + +test('eight-hour cutoff has coherent later departure delivery and driver end without rewriting other requests', () => { + const now = Date.parse('2030-01-01T00:00:00Z'), schedule = grayUiSchedule(now) + assert.equal(Date.parse(schedule.cutoffAt), now + 8 * 3600000) + assert.equal(Date.parse(schedule.plannedDepartureAt), now + 9 * 3600000) + assert.equal(Date.parse(schedule.plannedDeliveryAt), now + 10 * 3600000) + assert.equal(Date.parse(schedule.expectedDriverEndAt), now + 12 * 3600000) + assert.deepEqual(grayUiSchedule(now + 999), schedule) + assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/) + assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/) + const body = { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] } + const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) + assert.equal(applied.plannedDeliveryAt, schedule.plannedDeliveryAt) + assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.deepEqual(applied.fees, body.fees); assert.equal(body.plannedDeliveryAt, 'old') + assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body) + assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body) + assert.throws(() => grayUiSchedule(NaN), /CLOCK/) +}) + +test('four GRAY drafts are complete for both commercial types without creating reservations or commercial orders', () => { + const offer = { status: 'OPEN', canCreateInquiry: true, tripRecordVersion: 1, recordVersion: 2, destinations: [{ destinationKey: 'FBA:ONT8' }] } + const bodies = Array.from({ length: 4 }, (_, index) => uiInquiryBody(offer, index < 2 ? 'A' : 'B', index, `key-${index}`, true)) + assert.deepEqual(bodies.map(body => body.businessType), ['LOCAL_LOOSEBOARD', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'US_TRUCK']) + for (const body of bodies) { + assert.equal(body.expectedPalletCount, 1); assert.equal(body.cargo.packageCount, 1); assert.equal(body.cargo.palletSpaces, 1) + assert.equal('businessOrderNo' in body, false); assert.equal('status' in body, false) + } +}) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index 40f1fe274..b05c9973c 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -4,11 +4,12 @@ import { resolve, dirname, join, isAbsolute, extname } from 'node:path' import { homedir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' import { createHash, randomUUID } from 'node:crypto' -import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs' import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' @@ -41,6 +42,13 @@ export function parseUiFixtureArguments(args) { } export function uiFixturePlan(config) { + if (config.environment === 'GRAY') return { ...uiFixturePlan({ ...config, environment: 'TEST' }), environment: 'GRAY', + output: `reports/fleet-capacity-pool-ui-gray-${config.runId}`, + purpose: 'GRAY UI fixture only: one OPEN 30/18-space trip, four complete non-reserving drafts, two customer logins, sales A with quote:list, sales B without quote permission, manage dispatcher and one warehouse-bound PDA for H5.', + permissions: { salesA: uiActorPermissions('SALES_A', true), salesB: uiActorPermissions('SALES_B', true), manager: uiActorPermissions('MANAGER', true) }, + stopsAt: 'OPEN_AND_4_DRAFTS_READY_FOR_UI', retainedPda: true, + schedule: 'From preparation: cutoff +8h, departure +9h, delivery ETA +10h, driver expected end +12h; no actual departure.', + boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure or receipt signature. Retain business samples and all six UI logins including the warehouse-bound PDA; logout script sessions. Actual profile remains prod/gray; production-identity read-only evidence required.' } return { execute: Boolean(config.execute), environment: 'TEST', runId: config.runId, output: `reports/fleet-capacity-pool-ui-${config.runId}`, privateCredentialsFile: config.privateCredentialsFile, purpose: 'UI fixture only: one OPEN 30-space trip with valid 18-space owned plan and 24 non-reserving drafts, two minimal sales logins, one manage login, two qualified customer logins.', @@ -50,6 +58,29 @@ export function uiFixturePlan(config) { boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure, receipt signature or completed-order fabrication. Retain business samples and UI accounts; disable the fixture-only PDA and logout script sessions.' } } +export function uiActorPermissions(kind, gray = false) { + demand(['SALES_A', 'SALES_B', 'MANAGER'].includes(kind), 'UI_ACTOR_KIND_REQUIRED') + if (kind === 'MANAGER') return [PREFIX + 'manage'] + return [...['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name), + ...(gray && kind === 'SALES_A' ? ['platform:fleet:quote:list'] : [])] +} + +export function grayUiSchedule(now = Date.now()) { + demand(Number.isFinite(now), 'VALID_GRAY_UI_CLOCK_REQUIRED') + // Persisted business DATETIME columns have second precision. + const base = Math.floor(now / 1000) * 1000 + const instant = hours => new Date(base + hours * 3600000).toISOString() + const delivery = new Date(base + 10 * 3600000) + return { cutoffAt: instant(8), plannedDepartureAt: instant(9), expectedDriverEndAt: instant(12), + plannedDeliveryAt: new Date(delivery.getTime() - delivery.getTimezoneOffset() * 60000).toISOString().slice(0, 19) } +} + +export function applyGrayUiTransportWindow(method, path, body, schedule) { + if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body + demand(body && schedule?.plannedDeliveryAt, 'GRAY_UI_DELIVERY_WINDOW_REQUIRED') + return { ...body, plannedDeliveryAt: schedule.plannedDeliveryAt } +} + /** Private directory override is for isolated offline tests, never exposed as a CLI option. */ export function createPrivateCredentialStore(file, initial, { privateDirectory = PRIVATE_DIRECTORY } = {}) { const target = privateCredentialsPath(file, privateDirectory) @@ -75,36 +106,50 @@ export function publicUiEvidence(value, secrets = []) { return stripped(redactFleetWrite(value, secrets)) } -export function uiInquiryBody(offer, customerId, index, key) { +export function uiInquiryBody(offer, customerId, index, key, complete = false) { const destination = offer.destinations?.find(row => row.destinationKey?.startsWith('FBA:')) demand(offer.status === 'OPEN' && offer.canCreateInquiry && destination, 'OPEN_FBA_OFFER_REQUIRED') return { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion, customerId, businessType: index % 2 === 0 ? 'LOCAL_LOOSEBOARD' : 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', - expectedPalletCount: index === 0 ? null : 1, + expectedPalletCount: index === 0 && !complete ? null : 1, cargo: { destinationKey: destination.destinationKey, cargoName: `UI验收意向-${index + 1}`, - packageCount: index === 0 ? null : 1, palletSpaces: index === 0 ? null : 1, weightKg: 100, volumeCbm: 1, + packageCount: index === 0 && !complete ? null : 1, palletSpaces: index === 0 && !complete ? null : 1, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'UI验收联系人', contactPhone: '19900000000', remark: '独立UI验收草稿,不占位' }, idempotencyKey: key } } -export async function runUiFixture(config, { env = process.env, outputRoot = ROOT, readers, - prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}) { +export async function runUiFixture(config, dependencies = {}) { + demand(config.environment === 'TEST', 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED') + return runUiFixtureCore(config, dependencies, false) +} + +export async function runGrayUiFixture(config, dependencies = {}) { + demand(config.environment === 'GRAY', 'EXPLICIT_GRAY_UI_FIXTURE_CONFIGURATION_REQUIRED') + demand(Object.keys(dependencies).every(key => ['env', 'outputRoot'].includes(key)), 'GRAY_UI_CANNOT_OVERRIDE_EVIDENCE_OR_LIFECYCLE') + return runUiFixtureCore(config, dependencies, true) +} + +async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, readers, + prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}, gray = false) { if (!config.execute) return uiFixturePlan(config) - demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + demand((gray ? ['prod', 'gray'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) && config.runId !== 'plan' && /^[1-9]\d*$/.test(config.warehouseId) && /^[1-9]\d*$/.test(config.destinationWarehouseId) - && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED') + && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, `EXPLICIT_${gray ? 'GRAY' : 'TEST'}_FIXTURE_CONFIGURATION_REQUIRED`) const privateFile = privateCredentialsPath(config.privateCredentialsFile) const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) - demand(['http:', 'https:'].includes(endpoint.protocol) && ['/api', '/api/'].includes(endpoint.pathname) - && !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_TEST_API_BASE') - readers ||= createTestEvidenceReaders(env) - const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), - environment: 'test', gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, - markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) - const secretDocument = { schemaVersion: 1, environment: 'TEST', runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(), + demand((gray ? endpoint.protocol === 'https:' : ['http:', 'https:'].includes(endpoint.protocol)) && ['/api', '/api/'].includes(endpoint.pathname) + && !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_UI_API_BASE') + if (gray) readers = createGrayEvidenceReaders(env) + else readers ||= createTestEvidenceReaders(env) + const reports = join(outputRoot, 'reports'), output = resolve(outputRoot, uiFixturePlan(config).output) + const transport = { gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + output, markerNamespace: createHash('sha256').update(`${config.environment}:${config.runId}`).digest('hex').slice(0, 12) } + const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({ + ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' }) + const secretDocument = { schemaVersion: 1, environment: config.environment, runtimeProfile: env.CHONGCHONG_ENV, runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(), gatewayOrigin: endpoint.origin, accounts: [], business: {} } const vault = createPrivateCredentialStore(privateFile, secretDocument) - const reports = join(outputRoot, 'reports'), output = join(reports, `fleet-capacity-pool-ui-${config.runId}`) + const schedule = gray ? grayUiSchedule() : null, draftCount = gray ? 4 : 24, perSales = draftCount / 2 const secrets = Object.entries(env).filter(([key]) => /PASSWORD|TOKEN|SECRET/.test(key)).map(([, value]) => value).filter(Boolean) const sessions = new Map(), kinds = new Map() let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report @@ -129,10 +174,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO for (const method of ['request', 'requestWithBody']) { const original = context[method] context[method] = async function (...args) { - const sequence = ++requestNumber, [verb, path, body] = args + const sequence = ++requestNumber, [verb, path] = args + if (gray) args[2] = applyGrayUiTransportWindow(verb, path, args[2], schedule) + const body = args[2] if (verb === 'POST' && path === `${IAM}/users` && body?.username && body.password) { remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'), - username: body.username, password: body.password, roleIds: body.roleIds, status: 'PREPARED' }) + username: body.username, password: body.password, roleIds: body.roleIds, warehouseId: body.overseasWarehouseId || null, status: 'PREPARED' }) } save(`requests/${sequence}.json`, { stage: 'INTENT', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body }) const response = await original.apply(this, args) @@ -184,10 +231,19 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip) let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) - // Keep UI logins useful beyond the producer's short smoke-test window, before its existing six-hour delivery ETA. + // GRAY delivery ETA was set on the producer's real transfer command, so all four times stay ordered. detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, - plannedDepartureAt: new Date(Date.now() + 4 * 3600000).toISOString(), expectedDriverEndAt: detail.trip.expectedDriverEndAt, - stops: detail.trip.stops, reason: 'UI验收窗口:四小时后计划发车,不执行发车' }) + plannedDepartureAt: schedule?.plannedDepartureAt || new Date(Date.now() + 4 * 3600000).toISOString(), + expectedDriverEndAt: schedule?.expectedDriverEndAt || detail.trip.expectedDriverEndAt, + stops: detail.trip.stops, reason: gray ? '灰度UI验收:统一截止、发车、到站与司机结束时间,不执行发车' : 'UI验收窗口:四小时后计划发车,不执行发车' }) + if (gray) { + check('GRAY trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt) + && Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt)) + for (const member of fixture.members) { + const delivery = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${member.customerId}`) + check('Original customer delivery ETA follows the same valid UI window', delivery.order.plannedDeliveryAt === schedule.plannedDeliveryAt) + } + } plan = await request('GET', `${tripPath}/capacity-plan`) await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) @@ -195,7 +251,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex') for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) { const suffix = key(kind).slice(0, 16), username = `pool_ui_${kind.toLowerCase()}_${suffix}` - const expected = kind === 'MANAGER' ? [PREFIX + 'manage'] : ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name) + const expected = uiActorPermissions(kind, gray) const candidates = expected.map(code => permissions.filter(row => row.permissionCode === code && row.status === 'ACTIVE')) check('Exact fixture permissions are installed', candidates.every(rows => rows.length === 1 && rows[0].id)) const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_UI_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}`, description: '仅本轮UI验收账号' }) @@ -213,28 +269,33 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO } detail = await request('GET', tripPath) const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion, - cutoffAt: new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(), + cutoffAt: schedule?.cutoffAt || new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(), contactName: 'UI验收调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, actorTokens[2]) let offer = (await request('POST', `${POOL}/${draft.offer.offerNo}/publish`, { tripRecordVersion: draft.offer.tripRecordVersion, offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm && offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12) proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo - for (let index = 0; index < 24; index++) { - const owner = index < 12 ? 0 : 1, member = fixture.members[owner] - const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`)), actorTokens[owner]) + for (let index = 0; index < draftCount; index++) { + const owner = index < perSales ? 0 : 1, member = fixture.members[owner] + const body = uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`), gray) + if (gray) body.estimatedArrivalAt = new Date(Date.now() + 3600000).toISOString() + const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, body, actorTokens[owner]) check('UI inquiry is DRAFT with no commercial order or reservation', created.inquiry.status === 'DRAFT' && !created.inquiry.businessOrderNo && !created.inquiry.incomingNo && !created.inquiry.dispatchNo && Number(created.offer.destinations[0].remaining.palletSpaces) === 12) proof.drafts.push({ bookingNo: created.inquiry.bookingNo, customerId: member.customerId, owner: owner === 0 ? 'SALES_A' : 'SALES_B' }) offer = created.offer } const firstPage = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2]) - const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2]) - check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20 - && secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24) + if (gray) check('Manager sees exactly four current GRAY UI drafts', Number(firstPage.total) === 4 && firstPage.records.length === 4) + else { + const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2]) + check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20 + && secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24) + } for (let owner = 0; owner < 2; owner++) { const mine = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=MINE&offerNo=${offer.offerNo}`, undefined, actorTokens[owner]) - check('Each sales login sees exactly its own twelve drafts', Number(mine.total) === 12 && mine.records.length === 12 + check(`Each sales login sees exactly its own ${perSales} drafts`, Number(mine.total) === perSales && mine.records.length === perSales && mine.records.every(row => proof.drafts.some(draft => draft.bookingNo === row.bookingNo && draft.owner === (owner === 0 ? 'SALES_A' : 'SALES_B')))) } for (const member of fixture.members) { @@ -256,11 +317,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) } const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2]) - check('UI handoff keeps publication OPEN and 24 drafts untouched', final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === 24) - proof.status = 'OPEN_AND_24_DRAFTS_READY_FOR_UI' + check(`UI handoff keeps publication OPEN and ${draftCount} drafts untouched`, final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === draftCount) + proof.status = `OPEN_AND_${draftCount}_DRAFTS_READY_FOR_UI` secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt, - plannedDepartureAt: final.offer.plannedDepartureAt, customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) } + plannedDepartureAt: final.offer.plannedDepartureAt, ...(gray ? { uiSchedule: schedule } : {}), + customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) } secretDocument.status = 'READY_FOR_UI'; writePrivate() report = { success: true, fixtureReady: true, fullAcceptance: false, proof } } catch (error) { @@ -270,7 +332,11 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO try { if (context) { const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId) - if (picker?.membershipId || pdaAccount?.membershipId) { + if (gray && pdaAccount) { + pdaAccount.status = 'RETAINED_FOR_UI'; pdaAccount.purpose = 'GRAY_WAREHOUSE_BOUND_PDA_H5_ACCEPTANCE' + pdaAccount.warehouseId = config.warehouseId + report.retainedPda = { membershipId: pdaAccount.membershipId, warehouseId: config.warehouseId, purpose: pdaAccount.purpose } + } else if (!gray && (picker?.membershipId || pdaAccount?.membershipId)) { try { const previousSteps = context.steps.length await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93) @@ -296,6 +362,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO if (!report.success) secretDocument.status = 'FAILED_RETAINED' writePrivate() report.privateCredentialsFile = privateFile; report.requestCount = requestNumber; report.inventorySnapshots = inventoryNumber + report.environment = config.environment; report.runtimeProfile = env.CHONGCHONG_ENV report.output = output if (initialized) save('report.json', report) } finally { diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index 809bd6654..f087ac219 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -3,8 +3,9 @@ import { execFileSync } from 'node:child_process' import { dirname, resolve, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { createHash } from 'node:crypto' -import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' @@ -38,20 +39,36 @@ export function crossCustomerPlan(config) { limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } } -export async function runCrossCustomer(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, - finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) { - const plan = crossCustomerPlan(config) +export async function runCrossCustomer(config, dependencies = {}) { + return runCrossCustomerCore(config, dependencies, false) +} + +/** Not a CLI mode: called only by the explicit GRAY pool entry with its verified pool scenario. */ +export async function runFleetPoolGrayCrossCustomer(config, dependencies = {}) { + if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory'].includes(key))) + throw Error('Gray pool cannot override evidence readers or fixture lifecycle') + if (config.execute && (typeof dependencies.verify !== 'function' || typeof dependencies.contextFactory !== 'function')) + throw Error('Gray pool requires its dedicated scenario and journaled context') + return runCrossCustomerCore(config, dependencies, true) +} + +async function runCrossCustomerCore(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, + finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) { + const environment = gray ? 'GRAY' : 'TEST', reportName = `${gray ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}` + const plan = { ...crossCustomerPlan(config), environment, output: `reports/${reportName}`, + ...(gray ? { scope: 'FLEET_CAPACITY_POOL_GRAY', stages: ['current pool fixture and full pool HTTP matrix', 'trip completion and separate resource release'] } : {}) } if (!config.execute) return plan - if (config.environment !== 'TEST' || env.CHONGCHONG_ENV !== 'test' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + if (config.environment !== environment || !(gray ? ['gray', 'prod'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || config.runId === 'plan' || !/^[1-9][0-9]{0,19}$/.test(config.warehouseId) || !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId) - || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('Explicit TEST configuration required') + || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error(`Explicit ${environment} configuration required`) const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) - if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error('Invalid TEST gateway') - readers ||= createTestEvidenceReaders(env) + if (!(gray ? base.protocol === 'https:' : ['http:', 'https:'].includes(base.protocol)) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error(`Invalid ${environment} gateway`) + if (gray) readers = createGrayEvidenceReaders(env) + else readers ||= createTestEvidenceReaders(env) // Load the reviewed producer before creating any resources. prepare ||= (await import('./admin-test-env-modules/fleet-cross-customer-fixture.mjs')).createFleetCrossCustomerFixture if (typeof prepare !== 'function' || typeof readers.readFleetInventory !== 'function') throw Error('Fixture producer and inventory reader required') - const reports = resolve(outputRoot, 'reports'), output = join(reports, `fleet-cross-${config.runId}`) + const reports = resolve(outputRoot, 'reports'), output = join(reports, reportName) mkdirSync(reports, { recursive: true }) const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0 @@ -61,14 +78,15 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = try { writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests')) - const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', 'scripts/fba-core-regression-evidence.mjs', + const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', gray ? 'scripts/gray-regression-evidence.mjs' : 'scripts/fba-core-regression-evidence.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') })) - save('manifest.json', { ...plan, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources }) + save('manifest.json', { ...plan, runtimeProfile: env.CHONGCHONG_ENV, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources }) await readers.preflight() - const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), - environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, - markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + const transport = { gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + output, markerNamespace: createHash('sha256').update(`${environment}:${config.runId}`).digest('hex').slice(0, 12) } + const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({ + ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' }) context = contextFactory(options) for (const method of ['request', 'requestWithBody']) { const original = context[method] @@ -92,7 +110,7 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = } } const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') - if (!ok(login) || !login.body.data?.accessToken) throw Error('TEST administrator login failed') + if (!ok(login) || !login.body.data?.accessToken) throw Error(`${environment} administrator login failed`) context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken const warehouses = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') if (!ok(warehouses) || !warehouses.body.data?.some(row => String(row.id) === config.warehouseId)) throw Error('Requested warehouse not available') @@ -137,6 +155,8 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps } report ||= { success: false } + report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV + if (gray) report.scope = 'FLEET_CAPACITY_POOL_GRAY' report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) } } diff --git a/scripts/gray-acceptance-entry-contract.test.mjs b/scripts/gray-acceptance-entry-contract.test.mjs new file mode 100644 index 000000000..170230adf --- /dev/null +++ b/scripts/gray-acceptance-entry-contract.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { AcceptanceRun, configureReadOnlyEvidence, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' + +const args = ['--environment', 'gray', '--modules', 'phase5-root-fulfillment', + '--gateway', 'https://gray.example.invalid', '--username', 'contract', '--password', 'contract', + '--pda-username', 'contract-pda', '--pda-password', 'contract-pda', + '--f40-tax-mode', 'TAX_REFUND', '--output', 'reports/gray-contract.json'] + +test('gray is explicit, keeps real environment identity and creates its own customer', () => { + const options = validateExecutionOptions(parseArguments(args)) + assert.equal(options.environment, 'gray') + assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment']) + const run = new AcceptanceRun(options) + assert.match(run.marker, /^GRAY-API-/) + assert.equal(run.context.f40TaxMode, 'TAX_REFUND') +}) + +test('gray cannot run unbounded full suite, extra modules, bulk data or TEST faults', () => { + assert.throws(() => parseArguments(['--environment', 'gray']), /显式指定/) + for (const extra of [ + ['--modules', 'phase4-cross-region-events'], + ['--modules', 'phase5-root-fulfillment,customers'], + ['--verify-unload-event-recovery', 'true'], ['--wait-split-count', '2'], + ]) assert.throws(() => validateExecutionOptions(parseArguments([...args, ...extra])), /gray/) + assert.throws(() => validateExecutionOptions(parseArguments([...args, '--f40-tax-mode', 'UNKNOWN'])), /f40-tax-mode/) +}) + +test('gray endpoint must be a single HTTPS origin and needs a named report', () => { + for (const endpoint of ['http://gray.example.invalid', 'https://gray.example.invalid/api', + 'https://user:secret@gray.example.invalid', 'https://gray.example.invalid?debug=1']) { + assert.throws(() => validateExecutionOptions(parseArguments([...args, '--gateway', endpoint])), /HTTPS/) + } + const options = parseArguments(args) + options.output = '' + assert.throws(() => validateExecutionOptions(options), /output/) +}) + +test('gray has no fallback to TEST account, gateway, customer or PDA environment variables', () => { + const keys = ['ADMIN_TEST_GATEWAY', 'ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'ADMIN_PDA_USERNAME', + 'ADMIN_PDA_PASSWORD', 'MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD', 'ADMIN_TEST_CUSTOMER_NAME', + 'ADMIN_GRAY_GATEWAY', 'ADMIN_GRAY_USERNAME', 'ADMIN_GRAY_PASSWORD', 'ADMIN_GRAY_PDA_USERNAME', + 'ADMIN_GRAY_PDA_PASSWORD', 'MERCHANT_GRAY_USERNAME', 'MERCHANT_GRAY_PASSWORD', 'ADMIN_GRAY_CUSTOMER_NAME'] + const saved = new Map(keys.map(key => [key, process.env[key]])) + try { + for (const key of keys) { + if (key.includes('GRAY')) delete process.env[key] + else process.env[key] = 'must-not-be-used' + } + const options = parseArguments(['--environment', 'gray', '--modules', 'phase5-root-fulfillment']) + for (const key of ['gateway', 'username', 'password', 'pdaUsername', 'pdaPassword', + 'merchantUsername', 'merchantPassword', 'customerName']) assert.equal(options[key], '') + process.env.ADMIN_GRAY_GATEWAY = 'https://gray.example.invalid' + assert.equal(parseArguments(['--environment', 'gray', '--modules', 'phase5-root-fulfillment']).gateway, + 'https://gray.example.invalid') + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +}) + +test('gray evidence preflight attaches only read-only readers, never constructs fault executors', async () => { + const run = new AcceptanceRun(validateExecutionOptions(parseArguments(args))) + const readReceiving = () => {}, readCompletion = () => {} + let preflight = false + const faults = new Proxy({}, { get() { throw Error('TEST fault factory must not be read') } }) + assert.equal(await configureReadOnlyEvidence(run, {}, () => ({ readReceiving, readCompletion, + preflight: async () => { preflight = true } }), faults), undefined) + assert.equal(preflight, true) + assert.equal(run.context.phase4ReceivingEvidenceReader, readReceiving) + assert.equal(run.context.f40ReadOnlyOutboxEvidence, readCompletion) + assert.equal(run.context.beforeLoadingComplete, undefined) + assert.equal(run.context.beforeReceivingComplete, undefined) + assert.equal(run.context.f40DeliveryEventFaultExecutor, undefined) +}) + +test('direct gray evidence use cannot bypass module/fault restrictions or failed preflight', async () => { + const options = validateExecutionOptions(parseArguments(args)) + const bad = { options: { ...options, verifyUnloadEventRecovery: true }, context: {} } + await assert.rejects(configureReadOnlyEvidence(bad, {}, () => { throw Error('must not get here') }), /故障注入/) + const run = new AcceptanceRun(options) + await assert.rejects(configureReadOnlyEvidence(run, {}, () => ({ + preflight: async () => { throw Error('gray SSH unavailable') }, + })), /gray SSH unavailable/) + assert.equal(run.context.phase4ReceivingEvidenceReader, undefined) +}) diff --git a/scripts/gray-regression-evidence.mjs b/scripts/gray-regression-evidence.mjs new file mode 100644 index 000000000..03921d033 --- /dev/null +++ b/scripts/gray-regression-evidence.mjs @@ -0,0 +1,118 @@ +// Gray evidence uses the registered production SSH target, never the TEST agent. +import { assertReadOnlySql, buildReceivingEvidenceSql, receivingEvidenceFromRows, + buildCompletionEvidenceSql, completionEvidenceFromRows } from './fba-core-regression-evidence.mjs' +import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' + +const transactionStart = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', + 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +export const GRAY_EVIDENCE_ENV_KEYS = ['JENKINS_URL', 'JENKINS_USER', 'JENKINS_API_TOKEN'] +export function validateGrayEvidenceConfig(env) { + if (GRAY_EVIDENCE_ENV_KEYS.some(key => typeof env[key] !== 'string' || !env[key].trim())) + throw Error('Missing gray evidence configuration') + const url = new URL(env.JENKINS_URL) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) + throw Error('Invalid gray evidence configuration') +} +const encodedExpression = value => '[' + Buffer.from(value).toString('base64').match(/.{1,4000}/g) + .map(part => `'${part}'`).join(',') + "].join('')" + +export function buildGrayEvidenceRemoteScript(service, sql) { + assertReadOnlySql(sql) + if (!['order', 'lastleg'].includes(service)) throw Error('Invalid gray evidence target') + const prefix = service.toUpperCase(), container = `firstleg-production-${service}` + const schema = `baiyue_${service}` + const composeService = service === 'lastleg' ? 'lastleg-service' : service + const python = `import base64,json,os,re,subprocess,urllib.parse +stage='CONFIG' +try: + expected=json.loads(base64.b64decode(expected_encoded)) + stage='CONTAINER_IDENTITY' + c=json.loads(subprocess.check_output(['docker','inspect','${container}'],text=True,stderr=subprocess.DEVNULL,timeout=10))[0] + labels=c.get('Config',{}).get('Labels',{}) + if c.get('Name')!='/${container}' or labels.get('com.docker.compose.project')!='firstleg-production' or labels.get('com.docker.compose.service')!='${composeService}' or c.get('State',{}).get('Running') is not True: raise RuntimeError('target') + e=dict(v.split('=',1) for v in c['Config']['Env'] if '=' in v) + stage='PROFILE' + if e.get('DEPLOY_ENV')!='production' or e.get('SPRING_PROFILES_ACTIVE')!='production': raise RuntimeError('profile') + stage='DATABASE_SOURCE' + for key in ['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD']: + if not expected.get(key) or e.get(key)!=expected[key]: raise RuntimeError('database source') + stage='DATABASE_IDENTITY' + u=urllib.parse.urlsplit(e['${prefix}_DB_URL'].removeprefix('jdbc:')) + if u.scheme!='mysql' or not u.hostname or u.path!='/'+ '${schema}' or u.username or u.password: raise RuntimeError('database identity') + childEnv=dict(os.environ,MYSQL_PWD=e['${prefix}_DB_PASSWORD']) + host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + sql=base64.b64decode('${Buffer.from(sql).toString('base64')}').decode('utf-8') + stage='MYSQL_EXECUTION' + p=subprocess.run(['mysql','--connect-timeout=8','--host='+host,'--port='+str(u.port or 3306),'--user='+e['${prefix}_DB_USERNAME'],'--batch','--raw','--skip-column-names','${schema}'],input=sql,capture_output=True,text=True,env=childEnv,timeout=35) + if p.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',p.stderr) + print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else: print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':True,'rows':[json.loads(line) for line in p.stdout.splitlines() if line.strip()]})) +except Exception: + print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'GRAY_'+stage+'_FAILED'})) +` + return `def cs=com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(com.cloudbees.plugins.credentials.common.StandardCredentials.class,jenkins.model.Jenkins.instance,null,null) +def key=cs.find{it.id=='firstleg-production-ssh'} +def hostCredential=cs.find{it.id=='firstleg-production-host'} +def known=cs.find{it.id=='firstleg-production-known-hosts'} +def environment=cs.find{it.id=='firstleg-production-env'} +if(key==null || hostCredential==null || known==null || environment==null) throw new RuntimeException('Gray evidence credentials unavailable') +def host=hostCredential.secret.plainText +if(!(host==~/[A-Za-z0-9][A-Za-z0-9.:-]*/) || !(key.username==~/[A-Za-z_][A-Za-z0-9_.-]*/)) throw new RuntimeException('Invalid gray SSH target') +def wanted=['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD'] +def expected=[:] +environment.content.getText('UTF-8').readLines().each{line -> + if(line.contains('=')){def pair=line.split('=',2); if(wanted.contains(pair[0]))expected[pair[0]]=pair[1]} +} +if(expected.size()!=wanted.size()) throw new RuntimeException('Gray evidence database configuration unavailable') +def script="expected_encoded='"+java.util.Base64.encoder.encodeToString(groovy.json.JsonOutput.toJson(expected).getBytes('UTF-8'))+"'\\n"+new String(java.util.Base64.decoder.decode(${encodedExpression(python)}),'UTF-8') +def dir=java.nio.file.Files.createTempDirectory('gray-evidence-ssh-').toFile() +try { + def k=new File(dir,'key'); k.text=key.privateKeys[0]; k.setReadable(false,false); k.setReadable(true,true); k.setWritable(false,false); k.setWritable(true,true) + def h=new File(dir,'known_hosts'); h.bytes=known.content.bytes + def p=new ProcessBuilder(['ssh','-i',k.path,'-o','BatchMode=yes','-o','ConnectTimeout=10','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=2','-o','StrictHostKeyChecking=yes','-o','UserKnownHostsFile='+h.path,key.username+'@'+host,'python3','-']).redirectErrorStream(true).start() + p.outputStream.write(script.getBytes('UTF-8')); p.outputStream.close() + def output=new StringBuffer(); def reader=p.consumeProcessOutputStream(output) + if(!p.waitFor(50,java.util.concurrent.TimeUnit.SECONDS)){p.destroyForcibly(); throw new RuntimeException('Gray evidence SSH timeout')} + if(p.exitValue()!=0) throw new RuntimeException('Gray evidence SSH failed') + reader.join(1000) + if(reader.isAlive()) throw new RuntimeException('Gray evidence SSH output incomplete') + print(output.toString()) +} finally {dir.listFiles().each{it.delete()};dir.delete()}` +} + +export function createGrayEvidenceReaders(env, fetcher = globalThis.fetch) { + validateGrayEvidenceConfig(env) + async function execute(service, sql) { + const script = buildGrayEvidenceRemoteScript(service, sql) + let response + try { + response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { + method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), + 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000), + }) + } catch { throw Error('Gray evidence transport failed') } + if (!response.ok) throw Error('Gray evidence HTTP ' + response.status) + let raw + try { raw = await response.text() } catch { throw Error('Gray evidence response failed') } + const lines = raw.split(/\r?\n/).filter(line => line.startsWith('GRAY_EVIDENCE_JSON=')) + if (lines.length !== 1) throw Error('Gray evidence JSON missing or ambiguous') + let result + try { result = JSON.parse(lines[0].slice('GRAY_EVIDENCE_JSON='.length)) } catch { throw Error('Gray evidence JSON invalid') } + if (result?.ok !== true || !Array.isArray(result.rows)) + throw Error(/^(MYSQL_[0-9]{3,5}|GRAY_(CONFIG|CONTAINER_IDENTITY|PROFILE|DATABASE_SOURCE|DATABASE_IDENTITY|MYSQL_EXECUTION)_FAILED)$/.test(result?.error) ? result.error : 'Gray evidence query failed') + return result.rows + } + return { + readReceiving: async scope => receivingEvidenceFromRows(scope, await execute('lastleg', buildReceivingEvidenceSql(scope))), + readCompletion: async scope => completionEvidenceFromRows(scope, await execute('order', buildCompletionEvidenceSql(scope))), + readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))), + preflight: async () => { + for (const service of ['order', 'lastleg']) { + const rows = await execute(service, [...transactionStart, "SELECT JSON_OBJECT('grayProbe',1);", 'COMMIT;'].join('\n')) + if (rows.length !== 1 || rows[0].grayProbe !== 1) throw Error('Gray evidence preflight failed') + } + }, + } +} diff --git a/scripts/gray-regression-evidence.test.mjs b/scripts/gray-regression-evidence.test.mjs new file mode 100644 index 000000000..d6231c97f --- /dev/null +++ b/scripts/gray-regression-evidence.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildGrayEvidenceRemoteScript, createGrayEvidenceReaders, validateGrayEvidenceConfig } from './gray-regression-evidence.mjs' + +const env = { JENKINS_URL: 'https://jenkins.example.invalid/', JENKINS_USER: 'reader', JENKINS_API_TOKEN: 'private-test-token' } +const probeSql = "SET NAMES utf8mb4 COLLATE utf8mb4_bin;\nSET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\nSELECT JSON_OBJECT('grayProbe',1);\nCOMMIT;" +test('gray reader refuses missing credentials, embedded URL credentials and SQL mutations', () => { + assert.throws(() => validateGrayEvidenceConfig({}), /Missing/) + assert.throws(() => validateGrayEvidenceConfig({ ...env, JENKINS_URL: 'https://secret@host.invalid' }), /Invalid/) + assert.throws(() => buildGrayEvidenceRemoteScript('auth', probeSql), /target/) + assert.throws(() => buildGrayEvidenceRemoteScript('order', probeSql.replace('SELECT JSON_OBJECT', 'DELETE JSON_OBJECT')), /Read-only/) +}) +test('gray SSH reader pins registered production host, known hosts, runtime and database credentials', () => { + const script = buildGrayEvidenceRemoteScript('order', probeSql) + for (const credential of ['ssh', 'host', 'known-hosts', 'env']) assert.ok(script.includes(`firstleg-production-${credential}`)) + assert.ok(script.includes('StrictHostKeyChecking=yes')) + assert.ok(script.includes('finally {dir.listFiles().each{it.delete()};dir.delete()}')) + assert.ok(!script.includes('java-test-agent') && !script.includes('RemotingDiagnostics')) + const encodedPython = [...script.matchAll(/\['([A-Za-z0-9+/=]+)'(?:,'[A-Za-z0-9+/=]+')*\]\.join\(''\)/g)] + .map(match => [...match[0].matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(part => part[1]).join('')) + const python = encodedPython.map(value => Buffer.from(value, 'base64').toString()).find(value => value.includes('subprocess')) + assert.ok(python?.includes("labels.get('com.docker.compose.project')!='firstleg-production'")) + assert.ok(python.includes("e.get(key)!=expected[key]")) + assert.ok(python.includes("u.path!='/'+ 'baiyue_order'")) + assert.ok(python.includes('READ ONLY') === false, 'SQL is encoded independently') + const lastleg = buildGrayEvidenceRemoteScript('lastleg', probeSql) + const payload = [...lastleg.matchAll(/\['([A-Za-z0-9+/=]+)'(?:,'[A-Za-z0-9+/=]+')*\]\.join\(''\)/g)] + .map(match => [...match[0].matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(part => part[1]).join('')) + .map(value => Buffer.from(value, 'base64').toString()).find(value => value.includes('subprocess')) + assert.ok(payload.includes("labels.get('com.docker.compose.service')!='lastleg-service'")) +}) +test('preflight performs only two scoped gray probes and never exposes remote raw errors', async () => { + const requests = [] + const readers = createGrayEvidenceReaders(env, async (url, options) => { + requests.push({ url: String(url), script: options.body.get('script') }) + return { ok: true, text: async () => 'GRAY_EVIDENCE_JSON={"ok":true,"rows":[{"grayProbe":1}]}\n' } + }) + await readers.preflight() + assert.equal(requests.length, 2) + assert.ok(requests.every(request => request.url === 'https://jenkins.example.invalid/scriptText')) + const failed = createGrayEvidenceReaders(env, async () => ({ ok: true, text: async () => 'MYSQL_PWD=hidden\nTraceback secret-value' })) + await assert.rejects(failed.preflight(), error => error.message === 'Gray evidence JSON missing or ambiguous') +}) +test('preflight rejects failed probes and ambiguous results', async () => { + for (const raw of ['GRAY_EVIDENCE_JSON={"ok":true,"rows":[{"grayProbe":0}]}', + 'GRAY_EVIDENCE_JSON={"ok":false,"error":"MYSQL_1146"}', + 'GRAY_EVIDENCE_JSON={}\nGRAY_EVIDENCE_JSON={}']) { + const readers = createGrayEvidenceReaders(env, async () => ({ ok: true, text: async () => raw })) + await assert.rejects(readers.preflight()) + } + const sourceFailure = createGrayEvidenceReaders(env, async () => ({ ok: true, + text: async () => 'GRAY_EVIDENCE_JSON={"ok":false,"error":"GRAY_DATABASE_SOURCE_FAILED"}' })) + await assert.rejects(sourceFailure.preflight(), { message: 'GRAY_DATABASE_SOURCE_FAILED' }) +}) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 2e8d955f3..4e698f3c7 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -6,54 +6,154 @@ "spotbugsMavenPlugin": "4.9.8.5" }, "topology": { - "modules": 22, - "codeModules": 21, - "services": 15 + "modules": 20, + "codeModules": 19, + "services": 13 }, "openapi": { - "documents": 49, - "coveredServices": 10, - "codeFirstServices": 5 + "documents": 72, + "coveredServices": 11, + "codeFirstServices": 2 }, "reports": { - "jacocoPresent": 21, - "spotbugsPresent": 21, - "spotbugsFindings": 780, - "spotbugsByPriority": { - "1": 1, - "2": 779 - }, - "spotbugsByCategory": { - "BAD_PRACTICE": 10, - "CORRECTNESS": 3, - "EXPERIMENTAL": 4, - "MALICIOUS_CODE": 699, - "MT_CORRECTNESS": 2, - "PERFORMANCE": 18, - "STYLE": 44 - } + "jacocoPresent": 19, + "spotbugsPresent": 19, + "spotbugsFindings": 777 }, "modules": [ - { "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 }, - { "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 }, - { "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 }, - { "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 }, - { "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 }, - { "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 }, - { "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 }, - { "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 }, - { "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 }, - { "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 }, - { "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 }, - { "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 }, - { "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 }, - { "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 }, - { "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 }, - { "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 }, - { "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 }, - { "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 }, - { "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 }, - { "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 }, - { "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 } - ] + { + "module": "common-core", + "jacocoCovered": 32, + "jacocoTotal": 78, + "jacocoPercentage": 41.03, + "spotbugsFindings": 6 + }, + { + "module": "common-web", + "jacocoCovered": 164, + "jacocoTotal": 188, + "jacocoPercentage": 87.23, + "spotbugsFindings": 1 + }, + { + "module": "common-security", + "jacocoCovered": 109, + "jacocoTotal": 202, + "jacocoPercentage": 53.96, + "spotbugsFindings": 6 + }, + { + "module": "common-openapi", + "jacocoCovered": 17, + "jacocoTotal": 37, + "jacocoPercentage": 45.95, + "spotbugsFindings": 0 + }, + { + "module": "common-audit", + "jacocoCovered": 168, + "jacocoTotal": 244, + "jacocoPercentage": 68.85, + "spotbugsFindings": 8 + }, + { + "module": "common-event-contract", + "jacocoCovered": 146, + "jacocoTotal": 164, + "jacocoPercentage": 89.02, + "spotbugsFindings": 18 + }, + { + "module": "gateway", + "jacocoCovered": 110, + "jacocoTotal": 156, + "jacocoPercentage": 70.51, + "spotbugsFindings": 4 + }, + { + "module": "auth", + "jacocoCovered": 372, + "jacocoTotal": 619, + "jacocoPercentage": 60.1, + "spotbugsFindings": 5 + }, + { + "module": "iam", + "jacocoCovered": 1872, + "jacocoTotal": 3032, + "jacocoPercentage": 61.74, + "spotbugsFindings": 60 + }, + { + "module": "order", + "jacocoCovered": 1116, + "jacocoTotal": 1605, + "jacocoPercentage": 69.53, + "spotbugsFindings": 47 + }, + { + "module": "firstleg", + "jacocoCovered": 7891, + "jacocoTotal": 11592, + "jacocoPercentage": 68.07, + "spotbugsFindings": 223 + }, + { + "module": "warehouse", + "jacocoCovered": 1767, + "jacocoTotal": 2922, + "jacocoPercentage": 60.47, + "spotbugsFindings": 64 + }, + { + "module": "warehouse-overseas-service", + "jacocoCovered": 403, + "jacocoTotal": 778, + "jacocoPercentage": 51.8, + "spotbugsFindings": 8 + }, + { + "module": "lastleg-service", + "jacocoCovered": 5088, + "jacocoTotal": 7832, + "jacocoPercentage": 64.96, + "spotbugsFindings": 162 + }, + { + "module": "file", + "jacocoCovered": 1046, + "jacocoTotal": 1614, + "jacocoPercentage": 64.81, + "spotbugsFindings": 33 + }, + { + "module": "print", + "jacocoCovered": 596, + "jacocoTotal": 695, + "jacocoPercentage": 85.76, + "spotbugsFindings": 12 + }, + { + "module": "audit-service", + "jacocoCovered": 76, + "jacocoTotal": 153, + "jacocoPercentage": 49.67, + "spotbugsFindings": 0 + }, + { + "module": "finance", + "jacocoCovered": 1665, + "jacocoTotal": 2236, + "jacocoPercentage": 74.46, + "spotbugsFindings": 97 + }, + { + "module": "integration-service", + "jacocoCovered": 1040, + "jacocoTotal": 1420, + "jacocoPercentage": 73.24, + "spotbugsFindings": 23 + } + ], + "measurementNote": "保留服务的历史质量观测,仅用于报告比较;拓扑与OpenAPI按当前源码核对,不代表当前覆盖率或静态分析已重新测量。" } diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 841efff6e..1efe09648 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -64,18 +64,26 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) - assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 49) + assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) + assert.equal(summary.openapi.documents, 72) + for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { + assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) + } + assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml')) + assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml')) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/fleet-driver-license-v1.yaml')) + assert.ok(summary.modules.every(row => !['gateway-overseas', 'event-bridge-service'].includes(row.module))) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml')) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) assert.equal(baseline.openapi.documents, summary.openapi.documents) - assert.equal(summary.openapi.coveredServices.length, 10) - assert.equal(summary.openapi.codeFirstServices.length, 5) + assert.equal(summary.openapi.coveredServices.length, 11) + assert.deepEqual(summary.openapi.codeFirstServices, ['audit-service', 'gateway']) assert.equal(baseline.mode, 'report-only') assert.deepEqual(baseline.topology, summary.topology) - assert.equal(baseline.modules.length, 21) + assert.equal(baseline.modules.length, 19) + assert.deepEqual(baseline.modules.map(row => row.module).sort(), summary.modules.map(row => row.module).sort()) assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') diff --git a/scripts/local-env.ps1 b/scripts/local-env.ps1 index 0722fa530..6ed894e93 100644 --- a/scripts/local-env.ps1 +++ b/scripts/local-env.ps1 @@ -1,6 +1,8 @@ $env:NACOS_SERVER_ADDR = '127.0.0.1:8848' $env:NACOS_NAMESPACE = 'public' $env:NACOS_GROUP = 'BAIYUE' +$env:NACOS_USERNAME = 'nacos' +$env:NACOS_PASSWORD = 'nacos' $env:SPRING_CLOUD_NACOS_CONFIG_ENCODE = 'UTF-8' $env:REDIS_HOST = '127.0.0.1' $env:REDIS_PORT = '6379' diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 4ccc1769c..dbfbb24ec 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,9 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '车次财务按不可变发车客户及派送来源批量只读查询,POST无业务写入;内部服务令牌保护,实收另按公网权限裁剪,不逐行查询或推算未分配收款'), feign('finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java', 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '公司车辆付款凭证按平台及费用ID校验;上传创建不可变对象且无远端幂等键,读取可重复,禁止传输层自动重试上传'), @@ -75,6 +78,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java', + 'file-service', 'lastleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 通过内部令牌按客户范围分批读取海外业务数据'), feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 7ccbb4500..94cdf6b22 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -50,6 +50,7 @@ const expectedBackgroundEdges = [ 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', + 'file-service->lastleg-service', 'file-service->order-service', 'file-service->warehouse-service', 'finance-service->lastleg-service', @@ -102,7 +103,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 70) + assert.equal(reviewed.length, 72) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -215,6 +216,8 @@ test('unified authority checks remain infrastructure edges and direct events rem 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' ], [ 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', 'READ', 'SAFE_READ' + ], [ + 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java', 'READ', 'SAFE_READ' ]]) assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) @@ -306,5 +309,6 @@ test('shared Feign timeout defaults and long-running export overrides do not dri assert.match(source, /read-timeout:\s*5000/) } const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') - assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 6) + assert.match(fileConfig, /lastlegExportProviderClient:\s+connect-timeout:\s*3000\s+read-timeout:\s*15000/) }) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index c37dcd5b7..e27d65b98 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -15,6 +15,29 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => { + for (const source of [coordinatorPipeline, pipeline]) { + assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: ''/) + const guard = source.match(/(?:requestedRepository|imageRepository)\.tokenize\('\/'\)\[0\] ==~ \/\(\?i\)([^\n]+)\//) + assert.ok(guard, 'registry host must reject reserved example domains') + const reservedHost = new RegExp(`^(?:${guard[1]})$`, 'i') + for (const repository of [ + 'registry.example.com/team/baiyue-firstleg', + 'registry.example.com/your-group/baiyue-firstleg', + 'EXAMPLE.COM:5000/team/image', + 'registry.example.net/team/image', + 'example.org/team/image' + ]) assert.ok(reservedHost.test(repository.split('/')[0]), repository) + for (const host of ['registry.company.com:5000', '10.0.0.10:10082', 'example.company.com']) { + assert.equal(reservedHost.test(host), false, host) + } + const mavenStage = source.match(/stage\('[^']*Maven[^']*'\)/) + assert.ok(mavenStage) + assert.ok(source.indexOf(guard[0]) < mavenStage.index, + 'invalid registry must fail before expensive Java builds') + } +}) + test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) }) diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 98dd5d0fd..09585a20f 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -5,7 +5,7 @@ - Mapper 只由 `BaiyueWarehouseApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - 负责国内收货仓及现有平台仓库主数据、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。海外仓业务只在本模块扩展“海外仓合作仓”仓库类型及其公共主数据契约。 - 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。 -- 海外接柜、卸货、入仓、换标、产品、箱号、库存、库内作业、出库和派送属于海外区域服务,不得落入本国内 Warehouse Service;全流程范围先读 `../../docs/海外仓业务/README.md`。 +- 海外接柜、卸货、入仓、换标、产品、箱号、库存、库内作业、出库和派送归 Lastleg,不落入本 Warehouse Service;海外实物资源归 Warehouse Overseas。已知主档或资源接口沿实际调用定位,只有跨模块流程范围不明时读 `../../docs/海外仓业务/README.md`。 - 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。 ## 模块入口