优化
This commit is contained in:
@@ -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` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
Vendored
+5
-2
@@ -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://')
|
||||
}
|
||||
|
||||
+4
-1
@@ -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 格式')
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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<ExportBatchResponse> batch(String internalToken, String exportType,
|
||||
|
||||
@@ -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 { }
|
||||
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "baiyue.file.export")
|
||||
public class ExportDefinitionRegistry {
|
||||
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
|
||||
private static final Set<String> 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,}");
|
||||
|
||||
@@ -46,7 +46,11 @@ public class ExportTaskService {
|
||||
}
|
||||
validateParameters(type, request.parameters());
|
||||
validateTypeParameters(type, request.parameters());
|
||||
String parametersJson = parametersJson(request.parameters());
|
||||
Map<String, Object> 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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` 传递来源托盘。
|
||||
|
||||
+1
-1
@@ -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` 进入。归档开工门禁仅用于历史追溯,不以新增路由推定业务已完成。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 仅用于历史事件读取 |
|
||||
|
||||
-14
@@ -261,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<byte[]> 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 目的仓",
|
||||
|
||||
@@ -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<String, String> 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<ExportColumn> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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<ExportBatchResponse> 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));
|
||||
}
|
||||
}
|
||||
+146
@@ -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<ExportColumn> 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<String, Object> 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<Map<String, Object>> 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> T query(Map<String, Object> parameters, Class<T> type) {
|
||||
Set<String> 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<String, Object> row(Object value, List<ExportColumn> columns) {
|
||||
JsonNode source = json.valueToTree(value);
|
||||
Map<String, Object> 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<String, Object> 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<ExportColumn> 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); }
|
||||
}
|
||||
@@ -61,11 +61,6 @@ public class FleetQuoteService {
|
||||
for(int i=0;i<request.rows().size();i++)rows.add(createOne(operator,new FleetQuoteModels.Create(request.idempotencyKey()+":"+(i+1),request.rows().get(i)),batchHash));
|
||||
return new FleetQuoteModels.ImportResult(rows.size(),List.copyOf(rows));
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public byte[] exportCsv(FleetQuoteModels.Query q) {
|
||||
var csv=new StringBuilder("\uFEFF报价号,用途,往来单位,发货城市,目的仓码,预估距离(mile),币种,整车自提(每单),整车派送(每单),散板派送(每托),生效日,失效日,状态,版本,备注\r\n");
|
||||
for(var r:list(q)){var d=r.details();csv.append(String.join(",",cell(r.quoteNo()),cell(d.kind()),cell(r.partyName()),cell(d.originCity()),cell(d.destinationCode()),cell(d.distanceMiles()),cell(d.currency()),cell(d.selfPickupPrice()),cell(d.fullDeliveryPrice()),cell(d.looseDeliveryPrice()),cell(d.validFrom()),cell(d.validTo()),cell(r.status()),cell(r.recordVersion()),cell(d.remark()))).append("\r\n");}
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
static FleetQuoteModels.Draft normalize(FleetQuoteModels.Draft d) {
|
||||
if(d.validTo().isBefore(d.validFrom()))throw bad("失效日不得早于生效日");
|
||||
if(d.selfPickupPrice()==null&&d.fullDeliveryPrice()==null&&d.looseDeliveryPrice()==null)throw bad("至少填写一种报价,留空表示未报价");
|
||||
@@ -81,7 +76,6 @@ public class FleetQuoteService {
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}}
|
||||
private <T>T read(String value,Class<T> 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);}
|
||||
}
|
||||
|
||||
-2
@@ -35,7 +35,5 @@ public class PlatformFleetQuoteController {
|
||||
public ApiResponse<FleetQuoteModels.Quote> 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<FleetQuoteModels.ImportResult> 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<byte[]> 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();}
|
||||
}
|
||||
|
||||
-5
@@ -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<TripOperationsModels.Result> 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<byte[]> 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<TripOperationsFinanceService.Result> finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
|
||||
-10
@@ -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<TripOperationsModels.Money> amounts(Map<String,BigDecimal> 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); }
|
||||
}
|
||||
|
||||
-22
@@ -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<byte[]> 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<byte[]> 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<byte[]> csv(byte[] content, String filename) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+4
@@ -91,6 +91,9 @@ public interface InventoryQueryMapper {
|
||||
long countBalances(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.BalanceQuery query);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
|
||||
long countSourceBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
|
||||
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InventoryBalance> sourceBalances(@Param("customerId") String customerId,
|
||||
@@ -148,6 +151,7 @@ public interface InventoryQueryMapper {
|
||||
InventoryBalance visibleBalance(@Param("balanceId") long balanceId);
|
||||
|
||||
String LEDGER_FILTER = " WHERE 1=1" +
|
||||
"<if test='q.customerName != null and q.customerName != ""'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
|
||||
+5
-64
@@ -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<InventoryBalanceView> 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<InventoryBalanceView> 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<InventoryLedgerView> 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("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
|
||||
-7
@@ -47,12 +47,5 @@ public class PlatformMonthlyExchangeRateController {
|
||||
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
|
||||
return ApiResponse.success(service.changeStatus(month, operator(user), request));
|
||||
}
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
|
||||
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
|
||||
-16
@@ -99,21 +99,6 @@ public class MonthlyExchangeRateService {
|
||||
return detail(current.getRateMonth());
|
||||
}
|
||||
|
||||
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
|
||||
validateRange(query.monthFrom(), query.monthTo());
|
||||
String status = optionalStatus(query.status());
|
||||
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
|
||||
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
|
||||
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
|
||||
.eq(status != null, MonthlyExchangeRate::getStatus, status)
|
||||
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
|
||||
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
|
||||
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
|
||||
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
|
||||
String beforeStatus, String afterStatus, String operator, String remark) {
|
||||
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
|
||||
@@ -150,7 +135,6 @@ public class MonthlyExchangeRateService {
|
||||
}
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
|
||||
-11
@@ -92,17 +92,6 @@ public class PlatformOverseasQuoteController {
|
||||
return ApiResponse.success(service.changeStatus(id, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered overseas warehouse quotes",
|
||||
description = "Required authority: platform:overseas-master-data:quote:export")
|
||||
public ResponseEntity<byte[]> export(@Valid OverseasQuoteRequests.Query query) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv")
|
||||
.body(service.exportCsv(query));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN";
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
|
||||
-16
@@ -138,21 +138,6 @@ public class OverseasQuoteService {
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(OverseasQuoteRequests.Query query) {
|
||||
OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(),
|
||||
query.warehouseId(), query.providerName(), query.currency(), query.status());
|
||||
StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n");
|
||||
for (var row : page(exportQuery).records()) {
|
||||
csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',')
|
||||
.append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',')
|
||||
.append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',')
|
||||
.append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',')
|
||||
.append(cell(row.versionCode())).append(',').append(row.status()).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
}
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void replaceChildren(long quoteId, List<OverseasQuoteRequests.Fee> feeRows,
|
||||
List<OverseasQuoteRequests.TransitPrice> transitRows) {
|
||||
fees.delete(new LambdaQueryWrapper<OverseasQuoteFee>().eq(OverseasQuoteFee::getQuoteId, quoteId));
|
||||
@@ -221,7 +206,6 @@ public class OverseasQuoteService {
|
||||
private static long version(Long value) { return value == null ? 0L : value; }
|
||||
private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
|
||||
-7
@@ -76,13 +76,6 @@ public class PlatformVehicleController {
|
||||
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
|
||||
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
|
||||
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
if (user == null) return "UNKNOWN";
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
|
||||
-13
@@ -100,18 +100,6 @@ public class VehicleApplicationService {
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
public byte[] exportCsv(VehicleModels.Query query) {
|
||||
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
|
||||
StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n");
|
||||
for (var row : rows) csv.append(cell(row.plateNumber())).append(',')
|
||||
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
|
||||
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
|
||||
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
|
||||
.append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',')
|
||||
.append(row.updatedAt()).append("\r\n");
|
||||
return csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private SearchCriteria criteria(VehicleModels.Query query) {
|
||||
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
|
||||
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
|
||||
@@ -169,7 +157,6 @@ public class VehicleApplicationService {
|
||||
}
|
||||
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
|
||||
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
|
||||
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
|
||||
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
|
||||
|
||||
@@ -522,49 +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) {
|
||||
Page<LastlegOrder> result = orderMapper.selectPage(new Page<>(page, batchSize),
|
||||
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
|
||||
List<LastlegOrder> orders = result.getRecords();
|
||||
if (orders.isEmpty()) break;
|
||||
Map<String, String> 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();
|
||||
// The pagination interceptor may cap the requested batch size.
|
||||
if (!result.hasNext()) break;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
|
||||
return filter(customerId, query, null);
|
||||
}
|
||||
@@ -719,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; }
|
||||
@@ -735,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("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: 非平台作用域 }
|
||||
@@ -339,14 +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: [] }]
|
||||
parameters:
|
||||
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
|
||||
responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } }
|
||||
/api/platform/v1/last-leg/orders/container-summaries:
|
||||
get:
|
||||
summary: 分页查询柜级入库计划进度
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
-22
@@ -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, 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() {
|
||||
|
||||
+101
@@ -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<String,Object> p,String cursor,String snapshot) {
|
||||
return new ExportBatchRequest("t","0","PLATFORM","u",1,p,cursor,snapshot,1);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
-23
@@ -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<byte[]> 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<byte[]> 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() {
|
||||
|
||||
+10
-10
@@ -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());
|
||||
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -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,
|
||||
|
||||
@@ -389,51 +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<LastlegOrder>(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, 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 exportContinuesAfterThePaginationInterceptorCapsTheBatchSize() {
|
||||
when(orders.selectPage(any(), any())).thenAnswer(invocation -> {
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder> page = invocation.getArgument(0);
|
||||
page.setSize(500);
|
||||
page.setTotal(501);
|
||||
long start = (page.getCurrent() - 1) * page.getSize() + 1;
|
||||
page.setRecords(java.util.stream.LongStream.rangeClosed(start, Math.min(start + 499, 501))
|
||||
.mapToObj(id -> order(id, "100", "PENDING_ASSIGNMENT", null)).toList());
|
||||
return page;
|
||||
});
|
||||
var query = new LastlegOrderRequests.Query(1L, 20L, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE);
|
||||
|
||||
String csv = new String(service.exportCsv("100", query), StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(502, csv.lines().count());
|
||||
assertTrue(csv.contains("\"LM-501\""));
|
||||
verify(orders, org.mockito.Mockito.times(2)).selectPage(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageWarehouseFilterMatchesFrozenWarehouseName() {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
`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=<jenkins.env> --env-file=<gray-acceptance.env> scripts/fleet-capacity-pool-ui-fixture-gray.mjs --execute --environment GRAY --run-id <新批次> --warehouse-id <实际收货仓ID> --destination-warehouse-id <FBA目的仓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 <FBA目的仓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` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。
|
||||
|
||||
@@ -192,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 "<PDA实际绑定国内仓名>" --output "reports/<新批次>/gray-fba-flow.json"
|
||||
```
|
||||
|
||||
运行前须具备该灰度环境写入授权、所选仓的 PDA 账号、正式主档和有效价格簿;只读取证预检失败时不进入业务写入。报告显式标注 gray、使用 `GRAY-API-` 资源标记,并列出未覆盖范围。失败保留本轮资源和原始报告,不自动重放未知结果、不删样本、不重置数据库;先核对失败阶段再决定续跑,新运行使用独立报告路径和新样本。本链通过不代表默认全量、页面视觉或外部供应商已验收。
|
||||
|
||||
## 脚本目录归属
|
||||
|
||||
脚本按作用域放置,不要求全部集中到根 `scripts/`:
|
||||
@@ -220,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 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
@@ -291,7 +291,9 @@ function usage() {
|
||||
--password <value> 必填,管理员密码
|
||||
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
|
||||
--pda-password <value> 国内装柜链必填,PDA 作业员密码
|
||||
--environment <name> 默认 test;gray 仅允许显式 FBA 定向闭环
|
||||
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
|
||||
--f40-tax-mode <mode> F40 税制:BUY_DECLARATION 或 TAX_REFUND
|
||||
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
|
||||
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
|
||||
--merchant-password <value> 复用客户账号密码,不写入报告
|
||||
@@ -317,7 +319,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 +371,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 +394,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 +421,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 +433,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 +448,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 +597,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 +609,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 = ''
|
||||
@@ -1869,8 +1916,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 +1925,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 +1946,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')
|
||||
|
||||
@@ -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` 报告,失败先核对报告再重跑,不自动清理。
|
||||
|
||||
@@ -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` 校验失败不继续下载及文件内容验证,不访问环境。
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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']))
|
||||
})
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
const rows = [headers, values].map((row, index) => `<row r="${index + 1}">${row.map((value, col) => {
|
||||
const letter = String.fromCharCode(65 + col)
|
||||
return `<c r="${letter}${index + 1}" t="inlineStr"><is><t>${escape(value)}</t></is></c>`
|
||||
}).join('')}</row>`).join('')
|
||||
const name = Buffer.from('xl/worksheets/sheet1.xml')
|
||||
const body = Buffer.from(`<worksheet><sheetData>${rows}</sheetData></worksheet>`)
|
||||
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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 \}/)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '' }))
|
||||
})
|
||||
@@ -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/')))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] } }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 === '本轮测试处理人')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -122,13 +122,13 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目
|
||||
})
|
||||
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_firstleg.target, '7')
|
||||
assert.equal(targets.baiyue_finance.target, '2')
|
||||
assert.equal(targets.baiyue_lastleg.target, '7')
|
||||
assert.equal(targets.baiyue_lastleg.target, '9')
|
||||
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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user