Compare commits

..
75 changed files with 3034 additions and 452 deletions
@@ -0,0 +1,10 @@
import subprocess, shutil, xml.etree.ElementTree as ET
from pathlib import Path
root=Path.cwd()
report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml'
properties=ET.parse(report).getroot().find('properties')
classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path')
sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']]
subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True)
result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False)
raise SystemExit(result.returncode)
+5 -1
View File
@@ -1,6 +1,10 @@
# 单测试环境数据库空库初始化
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
@@ -0,0 +1,7 @@
-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
@@ -1,15 +0,0 @@
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -0,0 +1,4 @@
-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。
-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。
-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。
DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation;
@@ -6856,6 +6856,18 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order (
id bigint NOT NULL COMMENT '雪花 ID',
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
@@ -11,6 +11,8 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -71,6 +73,11 @@ enum BusinessFileCategory {
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
@@ -0,0 +1,14 @@
openapi: 3.0.3
info:
title: 自有业务排车运营照片归属契约
version: 1.0.0
description: >-
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
paths: {}
@@ -0,0 +1,125 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OwnedBusinessPhotoFileTest {
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
@TempDir Path root;
@BeforeAll static void columns() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
for (long owner : new long[]{-1, 1, 900})
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
var category = BusinessFileCategory.require(type);
for (String format : new String[]{"jpeg", "png"}) {
byte[] bytes = image(format);
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
var saved = storage.store(0, "71", upload, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
}
for (var upload : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
MultipartFile oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
verify(oversized, never()).getInputStream();
}
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
byte[] bytes = image("png");
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
assertThat(bound(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
});
when(storage.read("owned-photo")).thenReturn(bytes);
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
clearInvocations(storage);
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
.matcher(query.getSqlSegment());
assertThat(matcher.find()).as("scope column %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
private static byte[] image(String format) throws Exception {
var bytes = new ByteArrayOutputStream();
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
return bytes.toByteArray();
}
}
@@ -23,6 +23,7 @@ public interface MerchantWorkbenchFilterMapper {
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED'
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 客户工作台筛选事实的持久化入口。 */
@Repository
public class MerchantWorkbenchFilterRepository {
private final MerchantWorkbenchFilterMapper mapper;
/** 注入客户隔离的筛选查询。 */
public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) {
this.mapper = mapper;
}
/** 查询物流主状态对应的订单范围。 */
public List<Long> byProcessStatuses(long tenantId, List<String> statuses) {
return mapper.byProcessStatuses(tenantId, statuses);
}
/** 只返回最新已推送费用仍待客户确认的订单。 */
public List<Long> pendingFees(long tenantId) {
return mapper.pendingFees(tenantId);
}
/** 查询待客户补齐报关资料的订单。 */
public List<Long> pendingCustoms(long tenantId) {
return mapper.pendingCustoms(tenantId);
}
/** 查询客户订单的实际履约片段。 */
public List<MerchantWorkbenchFilters.Fragment> progressFragments(long tenantId, List<Long> orderIds) {
return mapper.progressFragments(tenantId, orderIds);
}
}
@@ -1,30 +1,30 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class MerchantWorkbenchFilterService {
@Resource private MerchantWorkbenchFilterMapper mapper;
@Resource private MerchantWorkbenchFilterRepository repository;
@Resource private UnifiedInboundQueryService unified;
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
/** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) {
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
progress(repository.progressFragments(request.tenantId(), request.orderIds())));
}
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId(), null)));
repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()),
progress(repository.progressFragments(request.tenantId(), null)));
}
/** 将实装片段聚合为订单级履约进度。 */
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
@@ -1,57 +0,0 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
@Service
public class ChargeableWeightService {
private final WorkflowFactRepository repository;
/** 注入当前事实和确认历史。 */
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
/** 客户仅能读取本订单已审核的计费重。 */
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
}
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
public List<Long> pendingOrders(long tenant) {
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
return List.copyOf(orders);
}
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
public static String fingerprint(WorkflowFacts.Fact f) {
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 未审核或非正数不允许确认。 */
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
/** 只认可当前版本的确认。 */
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
}
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
@Transactional
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
var inbound=repository.lock(tenant,id);
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
var replay=repository.command(tenant,operator,key);
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
var facts=repository.facts(List.of(id));
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
var f=facts.get(0);
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
var current=weight(f);
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
return weight(repository.facts(List.of(id)).get(0));
}
}
@@ -1,26 +0,0 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.Valid;
import java.util.List;
/** 客户计费重确认入口,独立于费用确认。 */
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantChargeableWeightController {
private final ChargeableWeightService service;
/** 注入独立确认服务。 */
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
/** 客户范围取认证上下文。 */
@GetMapping("/orders/{orderId}/chargeable-weights")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
/** 不触发费用确认与金额锁定。 */
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
@PostMapping("/chargeable-weights/{inboundId}/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 柜事实和独立确认SQL入口。 */
/** 柜事实与最新费用确认状态的只读 SQL 入口。 */
@Mapper
public interface WorkflowFactMapper {
String ACTUAL="""
@@ -13,29 +13,29 @@ public interface WorkflowFactMapper {
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
""";
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
/** 客户订单下全部独立入仓单。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
/** 客户工作台只读取审核通过且有正计费重的来源。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
List<Long> weightCandidates(@Param("tenantId") long tenantId);
/** 成员幂等键不允许跨确认对象复用。 */
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
/** 独立记录,不修改费用或物流主状态。 */
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
int insert(WorkflowFacts.Confirmation value);
/** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */
@Select("""
<script>
SELECT i.tenant_id, i.order_id,
CASE WHEN f.customer_confirmed_at IS NULL THEN 'WAIT_WEIGHT_CONFIRM'
ELSE 'WEIGHT_CONFIRMED' END AS main_status
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.deleted=0 AND i.process_status != 'CANCELLED'
AND i.measure_review_status='APPROVED' AND f.fee_status='SENT'
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
AND i.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</script>
""")
List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(@Param("ids") List<Long> ids);
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 本地柜事实与确认记录的持久化入口。 */
/** 本地柜事实与费用确认状态的只读持久化入口。 */
@Repository
public class WorkflowFactRepository {
private final WorkflowFactMapper mapper;
@@ -9,14 +9,8 @@ public class WorkflowFactRepository {
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空候选不读取数据。 */
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
/** 锁定所属客户来源。 */
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
/** 查询订单下独立来源。 */
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
/** 客户计费重待确认候选。 */
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
/** 读取成员确认命令。 */
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
/** 追加确认历史。 */
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
/** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */
public List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(List<Long> ids) {
return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids);
}
}
@@ -1,17 +1,12 @@
package com.baiyue.firstleg.workflow;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 页签事实与独立计费重确认契约。 */
/** 订单业务页签的只读事实契约。 */
public final class WorkflowFacts {
/** 仅承载查询及确认契约,不允许实例化。 */
/** 仅承载事实查询契约,不允许实例化。 */
private WorkflowFacts() {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
public record Source(long tenantId,long inboundId) {}
public record Request(List<Source> sources) {}
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
}
@@ -22,6 +22,11 @@ public class WorkflowTabService {
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
// 确认页签复用已推送费用版本。
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
}
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
@@ -46,11 +51,10 @@ public class WorkflowTabService {
}
return List.copyOf(result.values());
}
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
/** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
boolean signed="DELIVERED".equals(process);
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
if(!f.departed() && !signed) {
@@ -1,13 +0,0 @@
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post:
summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -388,7 +388,7 @@ paths:
required: [feeOrderIds, customsOrderIds, progress]
properties:
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}}
feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}}
customsOrderIds: {type: array, items: {type: integer, format: int64}}
progress:
type: array
@@ -1,64 +0,0 @@
openapi: 3.0.3
info: {title: 客户独立计费重确认, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights:
get:
summary: 客户读取本票全部独立入仓计费重
description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。
parameters:
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
responses:
'200':
description: 标准ApiResponse,认证客户无可确认来源时返回空数组
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: array
items: {$ref: '#/components/schemas/Weight'}
/api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm:
post:
summary: 客户独立确认当前计费重
description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedFingerprint]
properties:
expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'}
responses:
'200':
description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/Weight'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Weight:
type: object
required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed]
properties:
inboundId: {type: string, description: 独立来源入仓单ID}
orderId: {type: string, description: 独立订单ID}
chargeableWeightKg: {type: number}
fingerprint: {type: string}
confirmed: {type: boolean}
confirmedAt: {type: string, format: date-time, nullable: true}
@@ -19,6 +19,7 @@ class MerchantWorkbenchFilterMapperTest {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'");
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
@@ -33,7 +34,7 @@ class MerchantWorkbenchFilterMapperTest {
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class);
jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')");
jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)");
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@@ -9,25 +9,22 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest {
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
@Test void customerQueueUsesIndependentWeightConfirmation() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
/** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */
@Test void customerQueueUsesPublishedFeeConfirmation() {
var repository=mock(MerchantWorkbenchFilterRepository.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"weights",weights);
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
ReflectionTestUtils.setField(service,"repository",repository);
when(repository.pendingFees(11)).thenReturn(List.of(101L));
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
assertThat(response.feeOrderIds()).containsExactly(101L);
verify(weights).pendingOrders(11);
verify(mapper,never()).pendingFees(anyLong());
verify(repository).pendingFees(11);
}
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var mapper=mock(MerchantWorkbenchFilterRepository.class);
var unified=mock(UnifiedInboundQueryService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"repository",mapper);
ReflectionTestUtils.setField(service,"unified",unified);
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
@@ -4,8 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -15,8 +13,7 @@ import static org.mockito.Mockito.*;
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@@ -36,28 +33,4 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
}
/** 重测同值也要求重新确认,金额没有参与摘要。 */
@Test void repeatedMeasurementRequiresNewConfirmation() {
var original=fact();
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
}
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
var service=mock(ChargeableWeightService.class);
var controller=new MerchantChargeableWeightController(service);
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
var request=new WorkflowFacts.Confirm("a".repeat(64));
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
controller.read(merchant,101);
controller.confirm(merchant,1,"key",request);
verify(service).read(11,101);
verify(service).confirm(11,7,1,"key",request);
}
}
@@ -1,18 +1,15 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和独立计费重版本回归。 */
/** 取消回显、分柜隔离和资料页签回归。 */
class WorkflowBusinessTest {
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var time=LocalDateTime.of(2026,10,19,10,0);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
@@ -31,12 +28,12 @@ class WorkflowBusinessTest {
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
var f=fact(1,"100",null,true);
var f=fact(1,true);
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
@@ -47,17 +44,10 @@ class WorkflowBusinessTest {
}
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
@Test void independentWeightVersion() {
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
}
@@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.nio.file.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */
class WorkflowFactPersistenceTest {
/** 测试建表不连接业务数据库。 */
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
@Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
@@ -29,7 +29,6 @@ class WorkflowFactPersistenceTest {
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
@@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest {
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
assertThat(containers.hasActualVesselDeparture(5)).isOne();
assertThat(containers.hasActualVesselDeparture(6)).isZero();
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
assertThat(containers.hasActualVesselDeparture(6)).isZero();
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
assertThat(service.read(22,101)).isEmpty();
var repository=new WorkflowFactRepository(mapper);
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
@@ -92,29 +92,42 @@ class WorkflowFactPersistenceTest {
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
});
var current=service.read(11,101).get(0);
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
// 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。
var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class);
org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false))));
var tabs=new WorkflowTabService(repository,downstream);
var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity();
inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
// 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。
db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"))
.noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
assertThat(workbench.pendingFees(22)).containsExactly(102L);
db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1");
assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty();
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1");
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
assertThat(service.read(11,101)).hasSize(1);
assertThat(service.read(11,103)).hasSize(1);
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
// 父单仅作汇总时,不重复进入业务页签。
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
assertThat(service.read(11,101)).isEmpty();
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var child=service.read(11,103).get(0);
assertThat(child.confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3));
}
}
@@ -98,6 +98,12 @@ VALUES
(5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0),
(5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0),
(5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0),
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
@@ -161,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -0,0 +1,45 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
class FleetBusinessPermissionSeedSqlContractTest {
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(String sql:List.of(local,unified)) {
for(var scope:List.of("owned-business","outsourced-business")) {
long base=scope.equals("owned-business")?5107390L:5107400L;
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
}
}
}
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
}
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(long id:List.of(5107400L,5107401L,5107402L)) {
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
}
}
}
+1
View File
@@ -85,6 +85,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -24,6 +24,10 @@
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
### 自有车辆运营视图读取边界(2026-10-20)
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
@@ -32,7 +36,7 @@
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -9,6 +9,14 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
const ownedBusinessReadEdges = [
['model.OwnedBusinessModels', 'DispatchViews'],
['service.OwnedBusinessProjection', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchTypes'],
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
@@ -42,13 +50,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']);
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
}
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
@@ -58,13 +66,48 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'),
'dispatch must use fleet ports and guards, not its persistence mappers');
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(incomingEdges.length > 0);
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
});
test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' };
report.classDependencies.push(unreviewed);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
});
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
});
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
@Mapper
public interface DispatchNumberSequenceMapper {
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int allocate(@Param("date") LocalDate date);
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
@Repository
public class DispatchNumberSequence {
private final DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
mapper.allocate(date);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -269,7 +270,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
String number=numbers.next();
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
@Component
public class DispatchNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private final DispatchNumberSequence sequences;
private final Clock clock;
/** 正式运行按统一业务时区取当天日期。 */
@Autowired
public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
/** 固定时钟用于核实跨午夜归零边界。 */
DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
/** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
public String next() {
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
long value = sequences.next(date);
if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
}
}
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
public class OutsourcedBusinessController {
private final OutsourcedBusinessService service;
private final OwnedBusinessSupport access;
/** 会话只转真实平台操作者及其原权限集合。 */
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
/** 同筛选分页和完整阶段统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
/** 列表权限包含详情,不借原排车或自有写权限。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
/** 原主单和本页附加历史保持各自版本字段。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
}
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
}
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
}
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
}
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
public final class OutsourcedBusinessModels {
/** DTO容器不允许实例化。 */
private OutsourcedBusinessModels() {}
public static final String LIST="platform:fleet:outsourced-business:list";
public static final String EDIT="platform:fleet:outsourced-business:edit";
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
public record PhotoSummary(int loadingCount) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
@Service
public class OutsourcedBusinessService {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
}
/** 三个页签读取同一原排车详情和外配附加记录。 */
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
var result=queries.history(actor,number,page,size,SCOPE);
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
var ops=request.operations();
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
}
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
}
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
}
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
var original=photos.command(actor,number,key,SCOPE);Object result=null;
if(original.result()!=null)result=switch(original.action()) {
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
default->throw conflict("原命令类型不属于外配业务");
};
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
private Detail detail(OwnedBusinessModels.Detail detail) {
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
}
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
private Operations operations(OwnedBusinessModels.Operations ops) {
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
return new Operations(ops.money(),ops.arrivals());
}
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
private Photo photo(OwnedBusinessModels.Photo p) {
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
}
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
}
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 历史按本页版本顺序分页。 */
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
@Select("""
SELECT * FROM (
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
UNION ALL
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
""")
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
""";
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
AND o.vehicle_ownership=#{q.scope.ownership}
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
private Long allocatedPackages;
}
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
LocalDateTime from,LocalDateTime to) {}
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
@Data public static class StageCount { private String stage;private Long quantity; }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED("OWNED","platform:fleet:owned-business:"),
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
private final String ownership,prefix;
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
public String getOwnership(){return ownership;}
/** 查看权限包含本作用域详情、历史和照片读取。 */
public String listPermission(){return prefix+"list";}
/** 运营写权限独立,外配权限不映射成自有权限。 */
public String editPermission(){return prefix+"edit";}
/** 照片写权限独立,不扩大到原排车。 */
public String uploadPermission(){return prefix+"upload";}
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
/** 不存在只显示当前业务归属,不泄露内部身份。 */
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
@@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService {
public record Download(Photo photo,byte[] content) {}
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
}
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
if(slot==null || multipart==null)throw bad("请选择照片及分类");
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
@@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService {
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
} catch(RuntimeException failure) {
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
}
}
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
}
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
if(fileId<=0)throw bad("文件编号无效");
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null) {
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
return codec.read(previous.getResultJson(),Detail.class);
}
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
}
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
}
/** 五框历史授权仍由同车同版本的真实引用限定。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
if(revision==null)bound=projection.photos(repository.record(source.getId()));
else {
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
}
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing);
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
return new Download(photo,files.content(source.getId(),slot,fileId));
}
/** 恢复先校验原操作者,不披露他人载荷。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key) {
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
return command(actor,number,key,OwnedBusinessScope.OWNED);
}
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Component;
@@ -45,9 +46,15 @@ public class OwnedBusinessProjection {
}
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
}
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
@@ -61,11 +68,18 @@ public class OwnedBusinessProjection {
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
}
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
public String carrierName(Detail detail) {
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
}
/** 附加权限独立于原排车写动作。 */
private Gate gate(Actor actor,String permission) {
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
@@ -29,12 +30,19 @@ public class OwnedBusinessQueryService {
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) {
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
}
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to));
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
for(var c:counts) {
int n=Math.toIntExact(c.getQuantity());
@@ -44,18 +52,26 @@ public class OwnedBusinessQueryService {
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()),
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList();
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
}
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
public Detail detailForWrite(Actor actor,Source source) {
eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor);
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
@@ -64,23 +80,37 @@ public class OwnedBusinessQueryService {
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source);return source;
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
}
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
public Source requireScoped(String number,OwnedBusinessScope scope) {
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
}
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
private void eligible(Source source,OwnedBusinessScope scope) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
}
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize);
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
}
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
@@ -30,9 +31,16 @@ public class OwnedBusinessRecordService {
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request) {
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
return save(actor,number,request,OwnedBusinessScope.OWNED);
}
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
throw bad("外配业务不支持里程或油耗登记");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
var ops=normalize(source,request.operations());var row=lock(source,actor);
@@ -42,13 +50,18 @@ public class OwnedBusinessRecordService {
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
row.setArrivalsJson(codec.write(ops.arrivals()));
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason());
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
}
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
}
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
Object identity=new PhotoBinding(slot,request);
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
@@ -60,7 +73,7 @@ public class OwnedBusinessRecordService {
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
@@ -104,13 +117,14 @@ public class OwnedBusinessRecordService {
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) {
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
}
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
private Detail result(OwnedBusinessRows.Command command) {
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JavaType;
@@ -24,9 +25,13 @@ public class OwnedBusinessSupport {
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
public Actor actor(AuthenticatedUser user) {
return actor(user,OwnedBusinessScope.OWNED);
}
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
String operator=user.userId()==null?user.username():user.userId().toString();
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
@@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -0,0 +1,303 @@
openapi: "3.0.3"
info:
title: "原排车外配车辆运营视图"
version: "1.0.0"
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。"
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/outsourced-business:
get:
operationId: listOutsourcedBusiness
summary: "外配列表及同筛选四项完整统计"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" }
- { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } }
- { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" }
- { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" }
- { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } }
responses:
"200":
description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。"
content:
application/json:
schema: { "$ref": "#/components/schemas/ListResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusiness
summary: "外配原排车组合详情"
x-permission-code: "platform:fleet:outsourced-business:list"
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessHistory
summary: "原排车与运营附加历史分页"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
responses:
"200":
description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。"
content:
application/json:
schema: { "$ref": "#/components/schemas/HistoryResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
put:
operationId: saveOutsourcedBusinessOperations
summary: "登记或清空整车承运金额和逐目的仓实际到达"
x-permission-code: "platform:fleet:outsourced-business:edit"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/SaveOperations" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
post:
operationId: uploadOutsourcedBusinessPhoto
summary: "准备五框照片文件,尚未绑定"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, idempotencyKey]
properties:
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
"200":
description: "savedAt=null;未知结果保留原键查询,不重传。"
content:
application/json:
schema: { "$ref": "#/components/schemas/PhotoResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
put:
operationId: bindOutsourcedBusinessPhoto
summary: "以独立稳定键绑定原File编号"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/BindPhoto" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
get:
operationId: readOutsourcedBusinessPhotoContent
summary: "读取当前或精确历史版本的同车照片引用"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } }
responses:
"200":
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
content:
image/png: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessCommand
summary: "仅原操作账号查询原命令结果"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } }
responses:
"200":
description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。"
content:
application/json:
schema: { "$ref": "#/components/schemas/CommandResponse" }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } }
responses:
DetailResponse:
description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。"
content:
application/json:
schema:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } }
schemas:
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
Operations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.Operations
required: [money, arrivals]
properties:
money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true }
arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" }
SaveOperations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.SaveOperations
required: [recordVersion, idempotencyKey, operations]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
operations: { "$ref": "#/components/schemas/Operations" }
reason: { type: string, nullable: true, maxLength: 1000 }
BindPhoto:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.BindPhoto
required: [recordVersion, idempotencyKey, fileId]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
Vehicle:
type: object
x-java-record: OutsourcedBusinessModels.Vehicle
required: [vehicleId, plate, ownership, carrierName]
properties:
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
plate: { type: string, nullable: true }
ownership: { type: string, enum: [DISPATCHED] }
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
PhotoSlot:
type: string
enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT]
Photo:
type: object
x-java-record: OutsourcedBusinessModels.Photo
required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt, savedAt]
properties:
slot: { "$ref": "#/components/schemas/PhotoSlot" }
category: { type: string, enum: [LOADING] }
fileId: { type: string }
fileName: { type: string }
contentType: { type: string, enum: [image/png, image/jpeg] }
sizeBytes: { type: string }
uploadedAt: { type: string, format: date-time }
savedAt: { type: string, format: date-time, nullable: true }
PhotoSummary:
type: object
x-java-record: OutsourcedBusinessModels.PhotoSummary
required: [loadingCount]
properties:
loadingCount: { type: integer, minimum: 0, maximum: 5 }
Row:
type: object
x-java-record: OutsourcedBusinessModels.Row
required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, departedAt, deliveryCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, unitPrice, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues]
properties:
dispatchNo: { type: string }
stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" }
plannedDepartureAt: { type: string, format: date-time }
loadingCompletedAt: { type: string, format: date-time }
departedAt: { type: string, format: date-time, nullable: true }
deliveryCompletedAt: { type: string, format: date-time, nullable: true }
origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" }
vehicle: { "$ref": "#/components/schemas/Vehicle" }
driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" }
destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } }
actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" }
operations: { "$ref": "#/components/schemas/Operations" }
unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" }
recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" }
dispatchRecordVersion: { type: string }
photoSummary: { "$ref": "#/components/schemas/PhotoSummary" }
actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" }
sourceIssues: { type: array, items: { type: string } }
ListResult:
type: object
x-java-record: OutsourcedBusinessModels.ListResult
required: [records, page, size, total, summary]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/Row" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" }
Detail:
type: object
x-java-record: OutsourcedBusinessModels.Detail
required: [row, dispatch, cargo, photos]
properties:
row: { "$ref": "#/components/schemas/Row" }
dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" }
cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
History:
type: object
x-java-record: OutsourcedBusinessModels.History
required: [eventSource, recordVersion, dispatchRecordVersion, action, operatorName, occurredAt, reason, operations, photos]
properties:
eventSource: { type: string, enum: [DISPATCH, OPERATIONS] }
recordVersion: { type: string, nullable: true }
dispatchRecordVersion: { type: string, nullable: true }
action: { type: string }
operatorName: { type: string, nullable: true }
occurredAt: { type: string, format: date-time }
reason: { type: string, nullable: true }
operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
HistoryPage:
type: object
x-java-record: OutsourcedBusinessModels.HistoryPage
required: [records, page, size, total]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/History" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
Command:
type: object
x-java-record: OutsourcedBusinessModels.Command
required: [dispatchNo, action, idempotencyKey, state, recordVersion, replayed, result, errorCode, retryAllowed]
properties:
dispatchNo: { type: string }
action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] }
idempotencyKey: { type: string }
state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] }
recordVersion: { type: string, nullable: true }
replayed: { type: boolean }
result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] }
errorCode: { type: string, nullable: true }
retryAllowed: { type: boolean, enum: [false] }
ListResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } }
HistoryResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } }
PhotoResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } }
CommandResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } }
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 使用真实SQL、连接内流水与Spring事务代理核对唯一性和事务边界。 */
class DispatchNumberSequenceTest {
private static class Fixture {
final JdbcTemplate jdbc;
final DataSourceTransactionManager transactions;
final DispatchNumberSequence sequence;
final DispatchNumberSequenceMapper mapper;
/** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */
Fixture() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)");
var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class);
var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
sequence=(DispatchNumberSequence)proxy.getProxy();
}
}
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception {
var f=new Fixture();var today=LocalDate.of(2026,10,20);
assertThat(f.sequence.next(today)).isEqualTo(1);
assertThat(f.sequence.next(today)).isEqualTo(2);
assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1);
assertThat(f.sequence.next(today)).isEqualTo(3);
}
/** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */
@Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2);
try {
var work=new ArrayList<Callable<Long>>();for(int i=0;i<64;i++)work.add(()->f.sequence.next(date));
var values=new TreeSet<Long>();for(var future:pool.invokeAll(work))values.add(future.get());
assertThat(values).hasSize(64).first().isEqualTo(1L);
assertThat(values.last()).isEqualTo(64L);
} finally {pool.shutdownNow();}
}
/** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback");
})).isInstanceOf(IllegalStateException.class);
assertThat(f.sequence.next(date)).isEqualTo(2);
}
/** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);
f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date);
assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满");
assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L);
}
/** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */
@Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception {
var f=new Fixture();
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
});
}
}
@@ -536,6 +536,9 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.junit.jupiter.api.Test;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DispatchNumberGeneratorTest {
@Test void usesBusinessDateAndFourDigitsWithoutWarehouseOrCustomerSuffix() {
var sequences=mock(DispatchNumberSequence.class);
when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L,42L,9999L);
var numbers=new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T08:00:00Z"),ZoneOffset.UTC));
assertThat(numbers.next()).isEqualTo("PC202610200001");
assertThat(numbers.next()).isEqualTo("PC202610200042");
assertThat(numbers.next()).isEqualTo("PC202610209999");
}
@Test void switchesToTheNewDailySequenceAtShanghaiMidnight() {
var sequences=mock(DispatchNumberSequence.class);
when(sequences.next(LocalDate.of(2026,10,19))).thenReturn(238L);
when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L);
assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610190238");
assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610200001");
}
@Test void rejectsExhaustedOrInvalidSequenceInsteadOfWrappingOrReusingNumbers() {
for(long value:new long[]{0,10000}) {
var sequences=mock(DispatchNumberSequence.class);when(sequences.next(any())).thenReturn(value);
assertThatThrownBy(()->new DispatchNumberGenerator(sequences).next()).isInstanceOf(IllegalStateException.class);
}
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */
class OutsourcedBusinessContractTest {
/** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */
@Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception {
var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}";
assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class));
for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) {
String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null");
assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field);
}
assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class));
assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class));
}
/** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */
@Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of()));
assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals"));
assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km"));
assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size());
var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null));
assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size());
var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of()));
assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km"));
assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size());
}
/** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */
@Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception {
var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length);
for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class));
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class));
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class));
}
/** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */
@Test void controllerAlwaysRequiresTheActualOutsourcedPermission() {
int count=0;
for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) {
var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++;
String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";};
assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value());
assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope")));
}
assertEquals(8,count);
}
}
@@ -0,0 +1,265 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */
class OwnedBusinessScopePersistenceTest {
final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest();
OwnedBusinessQueryService queries;
OwnedBusinessRecordService records;
OwnedBusinessPhotoService photos;
OwnedBusinessFileGateway files;
OutsourcedBusinessService outsourced;
final Map<Long,OwnedBusinessFileGateway.Evidence> uploaded=new HashMap<>();
final byte[] png={(byte)137,80,78,71,13,10,26,10,0};
/** 只调用原测试的建库装配,不继承或重复运行其@Test。 */
@BeforeEach void setup() throws Exception {
baseline.setup();var reader=mock(DispatchDetailReader.class);
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec));
files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900);
when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{
long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence;
});
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.<Long>getArgument(2)));
when(files.content(anyLong(),any(),anyLong())).thenReturn(png);
photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files));
outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec);
insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商");
}
/** 数据库仅在本机隔离H2,用后关闭。 */
@AfterEach void close(){baseline.closeDatabase();}
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
@SuppressWarnings("unchecked") private <T>T tx(T target) {
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
}
/** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */
private void insert(long id,String mode,String stage,String carrier) {
var original=OwnedBusinessFixtures.source();
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
baseline.jdbc.update("""
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1
""",id*100,id,"PAL-"+(31+id),31+id);
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership);
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
}
/** 外配真实三权限,与自有集合完全不相交。 */
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
/** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */
private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List<OwnedBusinessModels.Arrival> arrivals) {
return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记");
}
/** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */
@Test void fullSummaryAndFilterUseOneFrozenScope() {
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query);
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary());
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total());
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total());
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total());
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
}
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName());
assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName());
assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion());
assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed());
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2));
}
/** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */
@Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() {
assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE"));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE"));
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of())));
assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key"));
assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key"));
assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of())));
var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions());
var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all);
assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2"));
assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null)));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
}
/** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */
@Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() {
baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212");
var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z"));
var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals);
var first=outsourced.save(actor(),"DS-OUT-2",body);
assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null));
assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body));
assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result());
var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode());
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals)));
var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals));
assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency());
var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of()));
assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null));
assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km());
assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
}
/** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */
@Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() {
var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null);
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED));
var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null);
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED));
assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null)))));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null)))));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files);
}
/** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */
@Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() {
String version="0",firstFile=null;int index=0;
for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) {
var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot;
var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt());
assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file));
assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId());
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion();
if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content());
}
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
final long oldId=Long.parseLong(firstFile);
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total());
assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null));
assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存")));
var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions());
assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE"));
assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png)));
verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
}
/** File结果未知只保存原意图,任何同键重放不得再次写File。 */
@Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() {
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断"));
var file=new MockMultipartFile("file","TEST.png","image/png",png);
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result());
assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */
@Test void frozenIdentityHashAndTimeCannotBeSubstituted() {
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99");
baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external);
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
}
/** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt())));
var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty());
}
/** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */
@Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() {
var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST;
var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png));
long fileId=Long.parseLong(uploadedPhoto.fileId());
when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId));
var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId());
assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body));
assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */
@Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() {
String prefix="a".repeat(63),exact=prefix+"1";
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact);
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) {
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different);
var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty());
}
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
}
/** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */
@Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() {
// 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)");
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)");
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)");
String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X";
baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction);
var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original",
new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason));
var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total());
var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action());
assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action());
assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName());
var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow();
assertEquals(operator,raw.getOperatorId());
}
}
@@ -12,23 +12,20 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
/** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */
@Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() {
@Test void calendarRequiresTripReadPermissionThroughMethodSecurity() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformFleetTripController.class);
var calendar = context.getBean(FleetTripCalendarService.class);
var from = java.time.Instant.parse("2026-10-01T00:00:00Z");
var to = from.plusSeconds(86400);
var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L,
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list"));
for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
}
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list"));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list"))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(calendar);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list"))));
user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
controller.calendar(user, from, to);
verify(calendar).calendar(from, to);
} finally { SecurityContextHolder.clearContext(); }
@@ -56,6 +56,11 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
],
"reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels",
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
"reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
+16
View File
@@ -267,6 +267,22 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
## 仓库级入口清单
排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。
`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=<TEST配置>` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-<run-id>/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。
`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。
`--scenario first-competition` 在真实整车拣货核平后,以同一当前版本、两个不同请求键同时提交一次Admin/PDA首次装车命令;只允许一方成功、另一方版本冲突,整车只增加一个版本,完整库存不变。真实PDA回执原样返回;若Admin胜出,保留胜者回执及PDA拒绝,停止既有PDA正常链,不冒充PDA成功、不重复装车或发运。PDA胜出时继续正常履约、原键双端竞争重放与结案。
`--scenario relabel` 在本轮双客户正常履约后,另建独立客户并以正式仓储转运/PDA实收三箱;三份箱唛换标由客户确认,两份TRUCK合入同一新实托,一份PICKUP自动建独立出库。核验零扫码取消PICKUP出库保留原确认成果与完整库存;两TRUCK来源按服务器完整selectionGroup建同车,2箱只算1实托,零扫码撤销后重新正式建车,再撤销本轮未执行安排。PICKUP取消后保持待独立安排,缺公开再次安排入口时明确未验证,不改成TRUCK或重做完工确认。所有成功开出的客户会话在finally退出;若换标检查失败且没有未知写,已完成的正常车次仍正式结案释放资源,原失败证据保留。
`--scenario relabel-shared --resume-report <原报告> --resume-report-sha256 <冻结哈希>` 仅继续本轮三份均已确认、PICKUP取消VERIFIED、尚无任何共享建单请求且正常车次已结案的换标候选读取失败检查点。核对当前原始库存及三申请确认成果不变后,按精确换标业务号读取候选,仍要求完整selectionGroup闭包;只首次建共享运输安排,不重建客户/实收/换标,不重复取消PICKUP,不重复正常履约或资源释放。任何未知写阻断此入口;原报告保持失败。
`--scenario bol-empty|bol-same|bol-different --pdf-python <已安装pypdf的Python绝对路径>` 分别创建同目的仓无预约号、同号、不同号的两客户新样本。预约场景通过原有来源预约入口登记明确标记TEST的确认资料及1箱真实份额,再按返回的batchKey/version/来源归属关联原生排车;不借旧预约,不新增日历功能。实际BOL PDF、独立只读二进制请求账本和提取文字保存在该批 `evidence/`;核对跨客户合并/分组、各托只打印一次、总计2箱、打印前后完整库存不变,再继续正常履约结案。JSON请求计数不含独立二进制GET,二者分别留证;不把文字检查作为视觉验收。
BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本轮一箱一托来源映射守恒,不声称PDF完整展示托盘码。若本轮在该显示断言处停止且尚未开始履约,可显式传原 `--resume-report` 和 `--resume-report-sha256` 到同一BOL场景、新runId;仅接受原READY夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。
下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。
| 分类 | 入口 | 用途与副作用 |
+9 -1
View File
@@ -1,5 +1,13 @@
# Admin 测试环境 HTTP 模块
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。
其BOL扩展通过可选`shipmentReferences`和`prepareBatchAllocations`为本轮实收来源提供显式TEST货件参考及正式预约份额;分配准备后重新读取候选当前版本,默认夹具不创建预约。`bol-empty/same/different` 核验真实PDF页数、约号、逐托唯一出现及箱数守恒,打印前后原始库存和主单快照完全不变;实际二进制请求另存只读账本。
其 `relabel` 扩展新建独立第三客户,以三箱正式转运来货和PDA实收准备真实换标来源,逐箱换标/物理移绑/完工/客户确认后分别验证PICKUP零扫码出库取消和两TRUCK申请同实托的原生建车、撤销与再次安排。它不调用要求历史五箱DS来源的旧生产入口,只复用其完整原生selectionGroup建单检查;非TRUCK再次安排缺入口时保留实际缺口。
`procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/<marker>.json`;客户会话在 finally 退出。由 `../procurement-sample-test-env.mjs` 注入本轮独立已充值客户、客户会话、有效收货仓、受控上传及权威钱包读取。创建新样本,执行正额货款、两次海外收货、两箱装箱、预估冻结、国际交寄、逐箱国内收货、实际核费及寄客户,逐阶段核对钱包现金/冻结和费用来源,关键请求原键重放。每次请求前必须 checkpoint;未知结果停写保留样本,不自动新键重试或清理。已接入显式 TEST 入口;未执行时不计作真实 HTTP 通过,也不覆盖异常、退款或权限全部矩阵。离线合同 `procurement-sample-fulfillment-contract.test.mjs` 验证前置缺失、余额不足、请求前持久化及未知结果停写。
`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
@@ -471,7 +479,7 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID;真实 HTTP 依赖 PDA 链产生工作事项。
- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次;2026-10-20 空参 HTTP 返回 `B0001`,已按非阻断缺口记录。
- `fleet-management` 只读(车次、出车记录、车队报价、拼车需求)已通过真实 HTTP;车次写命令仍需要本轮排车、车辆与司机资源。
- Client 海外仓只读列表与退货预报写链路已通过真实 HTTP(2026-10-20 Client 全量 406 项通过、2 项跳过);贴标、仓储转运、货物废弃和退货换标的创建链路仍需要平台海外 PDA 链产出的库存。
- Client 海外仓只读列表与退货预报写链路已通过真实 HTTP(2026-10-20 Client 全量 406 项通过、2 项跳过)。P5 换标创建仍失败:普通入仓候选 `cartonMark` 服务端返回 null,而创建接口强制要求 `cartonMark` 等于来源入仓单箱号(`cartonBelongsToInbound`),候选未提供来源箱号,属后端契约缺口;需补候选箱号或在普通入仓场景取消该校验后再验收。仓储转运、货物废弃与退货换标创建同样需要真实库存与箱号前置。
- `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。
- Client 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。
@@ -0,0 +1,802 @@
import assert from 'node:assert/strict'
import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs'
import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs'
import { nativeRelabelCreateBody } from '../overseas-relabel-real-acceptance.mjs'
import { finishFleetTrip } from './fleet-trip-fulfillment.mjs'
const ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const clone = value => structuredClone(value)
const id = value => String(value ?? '')
export const extensionMatrix = Object.freeze([
{ scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' },
{ scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' },
{ scenario: 'relabel', scope: 'Fresh three-box customer receipt, confirmed zero-scan cancellation, two applications sharing one pallet and native rearrangement', readiness: 'READY_FORMAL_THREE_BOX_RECEIPT' },
{ scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' },
{ scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' },
])
export function assertDualDestinationFixture(fixture) {
assert.equal(fixture.members?.length, 2)
assert.equal(new Set(fixture.members.map(row => id(row.customerId))).size, 2)
assert.equal(new Set(fixture.members.map(row => row.destination?.key)).size, 2)
assert(fixture.members.every(row => row.destination?.key && row.receiptVerified === true))
assert(fixture.tripProof?.stops?.length >= 3)
for (const member of fixture.members)
assert(fixture.tripProof.stops.some(stop => stop.destinationKey === member.destination.key))
}
export function feeCents(value) {
assert(['string', 'number'].includes(typeof value) && /^(0|[1-9]\d*)(\.\d{1,2})?$/.test(String(value)), 'Non-canonical fee amount')
const [whole, fraction = ''] = String(value).split('.')
const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'))
assert(Number.isSafeInteger(cents)); return cents
}
export function applyIndependentFees(body, groups, members, marker) {
assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2)
assert.equal(new Set(groups.map(row => row.groupKey)).size, 2)
assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2)
const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775]))
const expected = {}
for (const group of body.groups) {
const matches = groups.filter(row => row.groupKey === group.groupKey)
assert.equal(matches.length, 1)
const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId)
assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required')
assert(!expected[customerId], 'Duplicated customer fee group')
for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` }
expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) }))
}
return expected
}
/** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */
export async function discoverSecondDestination(context, primaryId) {
const get = async path => {
const response = await context.request('GET', path)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return response.body.data
}
const primary = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(primaryId)}?tenantId=0`)
const country = primary.addresses?.find(row => row.addressLine1)?.countryCode
assert(country && primary.warehouse?.warehouseType === 'FBA' && primary.warehouse.status === 'ACTIVE')
let total, scanned = 0
const seen = new Set()
for (let page = 1; page <= 100; page++) {
const data = await get(`/api/platform/v1/warehouse-admin?${new URLSearchParams({ tenantId: '0', page: String(page), size: '200', warehouseType: 'FBA', status: 'ACTIVE', countryCode: country })}`)
assert(Array.isArray(data.records)); assert.equal(Number(data.page), page); assert.equal(Number(data.size), 200)
assert(Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0)
total ??= Number(data.total); assert.equal(Number(data.total), total)
assert(data.records.length > 0 || scanned === total)
for (const row of data.records) {
assert(row.id && !seen.has(id(row.id))); seen.add(id(row.id)); scanned++
if (id(row.id) === id(primaryId)) continue
const candidate = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(row.id)}?tenantId=0`)
if (candidate.warehouse?.warehouseType !== 'FBA' || candidate.warehouse.status !== 'ACTIVE'
|| id(candidate.warehouse.tenantId) !== '0' || candidate.warehouse.warehouseCode === primary.warehouse.warehouseCode) continue
if (candidate.addresses?.some(address => address.addressLine1 && address.countryCode === country)) return id(row.id)
}
if (scanned === total) break
}
throw Error('No second distinct public active FBA destination with a real same-country address; no fixture created')
}
/** Reuse the formal full-chain verifier; only explicitly supplied synthetic fee amounts differ by customer. */
export async function verifyDualDestination(context, fixture, options) {
if (!options.bol) assertDualDestinationFixture(fixture)
else {
assert.equal(fixture.members.length, 2); assert.equal(new Set(fixture.members.map(row => row.customerId)).size, 2)
assert.equal(new Set(fixture.members.map(row => row.destination.key)).size, 1)
assert(fixture.members.every(row => row.receiptVerified === true))
}
const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker,
dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false,
hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] }
const original = context.request
let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false
const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
context.request = async function (method, path, body, ...rest) {
if (deferredFailure) throw deferredFailure
if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) {
proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker)
proof.arrangeRequest = clone(body); await context.checkpoint()
}
let response
if (options.firstCompetition && !firstRaceDone && method === 'POST' && path === `${pdaPath}/loading-complete`
&& fixture.tripProof.loadingCommand?.body?.idempotencyKey === body?.idempotencyKey) {
firstRaceDone = true
const outcome = await executeFirstLoadingRace(context, fixture, options, original.bind(this), body)
response = outcome.pdaResponse
if (outcome.error) deferredFailure = outcome.error
} else response = await original.call(this, method, path, body, ...rest)
if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement`
&& response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data)
// 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。
if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') {
try {
if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone
&& response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') {
raceDone = true
await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data)
}
if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone
&& response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) {
unarrangedDone = true
await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data)
}
if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) {
const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true }
for (let attempt = 1; attempt <= 60; attempt++) {
const current = await original.call(this, 'GET', mainPath)
assert.equal(current.http, 200); assert.equal(String(current.body?.code), '200')
assert.equal(current.body.data?.dispatchNo, fixture.dispatchNo)
fence.attempts.push({ attempt, stage: current.body.data.stage, recordVersion: current.body.data.recordVersion,
requestId: current.requestId, traceId: current.traceId })
if (current.body.data.stage === 'DELIVERED') { fence.status = 'DELIVERED'; break }
assert.equal(current.body.data.stage, 'DELIVERING')
await context.checkpoint(); await new Promise(resolve => setTimeout(resolve, 500))
}
assert.equal(fence.status, 'DELIVERED', 'Automatic completion did not settle within 30 seconds')
await context.checkpoint()
}
} catch (error) { deferredFailure = error }
}
return response
}
let verified
try { verified = await verifyFleetCrossCustomer(context, fixture, options) }
finally { context.request = original }
assert.equal(verified.status, 'VERIFIED')
const final = verified.snapshots.FINAL
assert.equal(new Set(final.dispatch.documents.deliveries.map(row => row.deliveryNo)).size, 2)
assert.equal(new Set(final.dispatch.documents.outbounds.map(row => row.outboundNo)).size, 2)
const feeIds = new Set()
for (const [index, member] of fixture.members.entries()) {
const delivery = final.members[index].delivery
assert.equal(id(delivery.order.customerId), member.customerId)
assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1)
const expected = proof.expectedFees[member.customerId]
assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length)
for (const fee of delivery.fees) {
assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id))
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE')
}
const other = fixture.members[1 - index]
const path = `${ROOT}/delivery-orders/${encodeURIComponent(delivery.order.deliveryNo)}?customerId=${encodeURIComponent(other.customerId)}`
const response = await context.request('GET', path)
const denied = [200, 403, 404].includes(response.http) && ['A0403', 'A0404'].includes(String(response.body?.code))
context.assert(MODULE, 'Foreign customer cannot read this delivery or its independent fees', response, denied, '', OPTIONS)
assert(denied)
proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo,
http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId })
}
assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId])
proof.status = 'VERIFIED'; await context.checkpoint(); return verified
}
const receipt = response => ({ http: response.http, code: response.body?.code, message: response.body?.message,
requestId: response.requestId, traceId: response.traceId })
async function competitionSnapshot(context, fixture, { readInventory }, send) {
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
const state = {}
for (const [name, target] of [['main', path], ['items', `${path}/items`], ['loading', `${path}/loading`],
['trip', `${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`]]) {
const response = await send('GET', target)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); state[name] = response.body.data
}
state.stock = []
for (const member of fixture.members) state.stock.push(await readInventory({ marker: context.marker,
customerId: member.customerId, warehouseId: fixture.warehouseId }))
return state
}
/** 仅两个请求同时发送;使用已成功装车的原键,明确区别首次执行竞争。 */
export async function verifyLoadingReplayRace(context, fixture, options, send, body, acknowledged) {
const proof = context.createdResources.dispatchWorkflowCompetition
assert(proof && proof.status === 'CHECKING')
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`
const pda = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`
const race = proof.loadingReplayRace = { status: 'INTENT', scope: 'ACKNOWLEDGED_COMMAND_TWO_ACTOR_REPLAY',
firstExecutionRace: 'NOT_TESTED', body: clone(body), before: await competitionSnapshot(context, fixture, options, send) }
await context.checkpoint()
const results = await Promise.allSettled([send('POST', pda, clone(body), fixture.pdaToken), send('POST', path, clone(body))])
race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' })
race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT'
await context.checkpoint()
for (const result of results) if (result.status === 'rejected') throw result.reason
const [pdaResult, adminResult] = results.map(result => result.value)
assert.equal(String(pdaResult.body?.code), '200'); assert.equal(pdaResult.body?.data?.replayed, true)
assert.deepEqual(pdaResult.body.data.result, acknowledged.result)
assert.equal(id(pdaResult.body.data.recordVersion), id(acknowledged.recordVersion))
assert.equal(String(adminResult.body?.code), 'A0403'); assert.match(adminResult.body?.message || '', /操作员/)
race.after = await competitionSnapshot(context, fixture, options, send)
assert.deepEqual(race.after, race.before)
context.assert(MODULE, 'Concurrent Admin/PDA original-key replays preserve the one loading result and complete inventories',
pdaResult, true, '', OPTIONS)
race.status = 'VERIFIED'; await context.checkpoint()
}
/** 首次命令使用两个不同键和同一真实版本;胜者必须唯一,真实PDA回执原样交还调用者。 */
export async function executeFirstLoadingRace(context, fixture, options, send, body) {
const proof = context.createdResources.dispatchWorkflowCompetition
assert(proof && proof.status === 'CHECKING')
const adminBody = { ...clone(body), idempotencyKey: `${context.marker}-EXT-FIRST-ADMIN-LOAD` }
const race = proof.firstLoadingRace = { status: 'INTENT', pdaBody: clone(body), adminBody,
before: await competitionSnapshot(context, fixture, options, send) }
assert.equal(race.before.main.stage, 'LOADING'); assert.equal(Number(race.before.main.counts.pickedPackageCount), 2)
assert.equal(id(race.before.main.recordVersion), id(body.recordVersion)); await context.checkpoint()
const results = await Promise.allSettled([
send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`, clone(body), fixture.pdaToken),
send('POST', `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`, adminBody),
])
race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' })
race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT'; await context.checkpoint()
for (const result of results) if (result.status === 'rejected') throw result.reason
const responses = results.map(result => result.value)
try {
const winnerIndexes = responses.map((response, index) => String(response.body?.code) === '200' ? index : -1).filter(index => index >= 0)
assert.equal(winnerIndexes.length, 1)
const winnerIndex = winnerIndexes[0], winner = responses[winnerIndex], loser = responses[1 - winnerIndex]
assert.equal(String(loser.body?.code), 'A0409')
assert.equal(winner.body.data.stage, 'AFTER_LOADING')
assert.equal(winner.body.data.result?.confirmedLoad?.manifestHash, body.manifestHash)
assert.equal(BigInt(winner.body.data.recordVersion), BigInt(body.recordVersion) + 1n)
race.after = await competitionSnapshot(context, fixture, options, send)
assert.equal(race.after.main.stage, 'AFTER_LOADING')
assert.equal(id(race.after.main.recordVersion), id(winner.body.data.recordVersion))
assert.deepEqual(race.after.stock, race.before.stock)
assert.equal(Number(race.after.main.counts.packageCount), 2)
assert.equal(Number(race.after.main.counts.pickedPackageCount), 2)
race.winner = winnerIndex === 0 ? 'PDA' : 'ADMIN'; race.winnerResult = clone(winner.body.data)
race.status = 'VERIFIED'; proof.firstExecutionRace = 'VERIFIED'
context.assert(MODULE, 'First valid Admin/PDA loading race commits exactly one version and preserves complete inventories', winner, true, '', OPTIONS)
await context.checkpoint()
if (winnerIndex !== 0) return { pdaResponse: responses[0], error: Error('Admin won the first loading race; original PDA rejection retained, no fulfillment replay') }
return { pdaResponse: responses[0] }
} catch (error) {
race.verificationError = error.message; await context.checkpoint()
return { pdaResponse: responses[0], error }
}
}
async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) {
const proof = context.createdResources.dispatchWorkflowCompetition
const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send),
body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } }
assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0)
await context.checkpoint()
const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken)
state.response = receipt(response); await context.checkpoint()
assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/)
state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before)
context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS)
state.status = 'VERIFIED'; await context.checkpoint()
}
export async function verifyCompetitionExtension(context, fixture, options) {
const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo,
status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' }
const before = await competitionSnapshot(context, fixture, options, context.request.bind(context))
const body = { idempotencyKey: `${context.marker}-EXT-OCCUPIED-RESOURCE`, warehouseId: fixture.warehouseId,
vehicle: { mode: 'MASTER', vehicleId: fixture.tripProof.vehicleId },
driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } },
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null,
remark: `${context.marker} TEST已占用资源不能分配另一空车`, cargo: [], batchAllocations: [] }
proof.resourceConflict = { status: 'INTENT', body: clone(body), before }; await context.checkpoint()
const response = await context.request('POST', `${ROOT}/dispatches`, body)
proof.resourceConflict.response = receipt(response); await context.checkpoint()
assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /车辆.*占用|司机.*占用/)
proof.resourceConflict.after = await competitionSnapshot(context, fixture, options, context.request.bind(context))
assert.deepEqual(proof.resourceConflict.after, before)
context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS)
proof.resourceConflict.status = 'VERIFIED'
const verified = await verifyDualDestination(context, fixture, { ...options, competition: true })
assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED')
if (options.firstCompetition) assert.equal(proof.firstLoadingRace?.status, 'VERIFIED')
proof.status = options.firstCompetition ? 'VERIFIED_FIRST_EXECUTION_AND_REPLAY' : 'VERIFIED_BOUNDED_SCOPE'
await context.checkpoint(); return verified
}
export const verifyFirstCompetitionExtension = (context, fixture, options) => verifyCompetitionExtension(context, fixture, { ...options, firstCompetition: true })
/** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */
export async function prepareBolAppointments(context, members, scenario, warehouseId) {
assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario))
const proof = context.createdResources.dispatchWorkflowBolAppointments = { scenario, status: 'PREPARING', appointments: [], allocations: [] }
if (scenario === 'bol-empty') { proof.status = 'NO_APPOINTMENT'; return [] }
const groups = scenario === 'bol-same' ? [members] : members.map(member => [member])
for (const [index, group] of groups.entries()) {
for (const member of group) {
const response = await context.request('GET', `${ROOT}/warehouse-transfers/${encodeURIComponent(member.incomingNo)}?customerId=${encodeURIComponent(member.customerId)}`)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200')
const transfer = response.body.data
assert.equal(transfer.transferNo, member.incomingNo); assert.equal(transfer.inboundCode, member.incomingNo)
assert.equal(id(transfer.inboundOrderId), id(member.receipt.balance.inboundOrderId))
assert.equal(id(transfer.operatingWarehouseId), warehouseId)
assert(transfer.lastlegOrderId && member.selection.planPalletId && member.receiptVerified)
member.appointmentSourceKey = `ORDER:${transfer.lastlegOrderId}`
}
const refs = group.map(member => {
return { customerId: member.customerId, sourceKey: member.appointmentSourceKey, destinationKey: member.destination.key }
})
const confirmationNo = `TEST-ISA-${context.marker.slice(-12)}-${index + 1}`
const body = { sources: refs, originWarehouseId: warehouseId, fbaWarehouseCode: group[0].destination.warehouseCode,
details: { appointmentStatus: 'SUCCESS', confirmationNo, appointmentAt: new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 19),
appointmentEndAt: new Date(Date.now() + 50 * 3600000).toISOString().slice(0, 19), appointmentRemark: `${context.marker} TEST合成预约确认资料` },
idempotencyKey: `${context.marker}-BOL-AP-${index + 1}` }
const record = { status: 'INTENT', body: clone(body) }; proof.appointments.push(record); await context.checkpoint()
const response = await context.request('POST', `${ROOT}/appointment-planning/appointments`, body)
record.response = receipt(response); record.status = 'RESPONDED'; await context.checkpoint()
const data = context.apiSuccess(MODULE, 'Create own formal FBA appointment for exact received quantities', response, '', OPTIONS)
assert.equal(String(response.body?.code), '200'); assert(data?.batchKey && data.appointmentNo)
assert.equal(data.confirmationNo, confirmationNo); assert.equal(data.appointmentStatus, 'SUCCESS')
assert.equal(data.items.length, group.length); assert(Number.isSafeInteger(data.version) && data.version >= 0)
record.data = clone(data)
for (const member of group) {
const matches = data.items.filter(item => id(item.customerId) === member.customerId && item.sourceKey === member.appointmentSourceKey)
assert.equal(matches.length, 1); assert.equal(Number(matches[0].packageCount), 1)
assert.equal(matches[0].destinationKey, member.destination.key)
proof.allocations.push({ planPalletId: member.selection.planPalletId, batchKey: data.batchKey, customerId: member.customerId,
sourceKey: matches[0].sourceKey, destinationKey: matches[0].destinationKey, packageCount: 1, recordVersion: data.version })
}
record.status = 'VERIFIED'; await context.checkpoint()
}
proof.status = 'VERIFIED'; return clone(proof.allocations)
}
export function assertBolPages(pages, fixture, appointments, scenario) {
const expected = scenario === 'bol-different' ? 2 : 1
assert.equal(pages.length, expected)
const counts = [], isas = [], flattened = pages.map(page => page.replace(/\s/g, ''))
for (const page of pages) {
assert.match(page, /BILL OF LADING/)
const total = page.match(/Total\s+CTNS:\s*(\d+)/)
assert(total, 'Printed total box count missing'); counts.push(Number(total[1]))
const isaLine = page.split(/\r?\n/).find(line => line.includes('ISA #:'))
assert(isaLine !== undefined); isas.push(isaLine.split('ISA #:')[1].trim())
}
assert.equal(counts.reduce((sum, count) => sum + count, 0), 2)
// 现行窄托盘列会省略长托盘码;用完整入仓码核对原实收来源,每个来源在本夹具中严格对应一箱一实托。
for (const member of fixture.members) {
assert(member.incomingNo && member.palletNo)
assert.equal(flattened.reduce((sum, page) => sum + page.split(member.incomingNo).length - 1, 0), 1)
}
const expectedIsas = appointments.appointments.map(row => row.data.confirmationNo)
assert.deepEqual([...isas].sort(), scenario === 'bol-empty' ? [''] : [...expectedIsas].sort())
if (scenario !== 'bol-different') assert.deepEqual(counts, [2])
else assert.deepEqual(counts, [1, 1])
return { pageCount: pages.length, totalPackages: 2, packagesPerBol: counts, confirmationNos: isas,
eachSourcePrintedOnce: true, physicalPalletMapping: 'ONE_BOX_ONE_PALLET_PER_SOURCE', palletColumn: 'LONG_CODES_TRUNCATED_BY_EXISTING_TEMPLATE' }
}
export async function verifyBolExtension(context, fixture, options) {
assert(typeof options.inspectBol === 'function')
const proof = context.createdResources.dispatchWorkflowBol = { scenario: options.bol, status: 'CHECKING',
physicalMeasurementVerified: false, hardwareVerified: false }
proof.before = await competitionSnapshot(context, fixture, options, context.request.bind(context))
const pages = await options.inspectBol(context, fixture)
proof.printed = assertBolPages(pages, fixture, context.createdResources.dispatchWorkflowBolAppointments, options.bol)
proof.after = await competitionSnapshot(context, fixture, options, context.request.bind(context))
assert.deepEqual(proof.after, proof.before)
context.assert(MODULE, 'Actual BOL PDF preserves appointment shares, cross-customer grouping and complete inventory facts',
{ http: 200 }, true, '', OPTIONS)
proof.status = 'VERIFIED'; await context.checkpoint()
return verifyDualDestination(context, fixture, options)
}
/** Exercise edits only on this fresh zero-picked main, then leave a new fully approved round for the formal chain. */
export async function exerciseReviewLifecycle(context, fixture, { readInventory }) {
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
const proof = context.createdResources.dispatchWorkflowReviews = { marker: context.marker, dispatchNo: fixture.dispatchNo,
status: 'CHECKING', commands: [], rejections: [], snapshots: {}, syntheticTestMaterials: true }
const request = async (method, target, body, label, reject = false) => {
if (method !== 'GET') { proof.commands.push({ method, path: target, body: clone(body) }); await context.checkpoint() }
const response = await context.request(method, target, body)
if (reject) {
const denied = [200, 409].includes(response.http) && ['A0409', '409'].includes(String(response.body?.code))
context.assert(MODULE, label, response, denied, '', OPTIONS); assert(denied)
proof.rejections.push({ label, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId })
return response
}
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data
}
const get = (target, label) => request('GET', target, undefined, label)
const snapshot = async label => {
const data = { main: await get(path, label), items: await get(`${path}/items`, label), route: await get(`${path}/route`, label), stock: [] }
for (const member of fixture.members) data.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }))
proof.snapshots[label] = data; await context.checkpoint(); return data
}
const initial = await snapshot('INITIAL_APPROVED_ZERO_PICK')
assert.equal(initial.main.stage, 'LOADING'); assert.equal(initial.main.reviewState, 'APPROVED')
assert.equal(Number(initial.main.counts.pickedPackageCount), 0); assert.equal(Number(initial.main.counts.shippedPackageCount), 0)
const key = label => `${context.marker}-REV-${label}`
let result = await request('POST', `${path}/return-to-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('RETURN'), reason: 'TEST零扫码显式退回改货改车' }, 'Zero-pick explicit return')
assert.equal(result.stage, 'BEFORE_LOADING')
let before = await snapshot('RETURNED')
assert.equal(before.main.reviewState, 'DRAFT')
await request('POST', `${path}/submit-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('STALE'), remark: context.marker }, 'Stale main version is rejected', true)
assert.deepEqual(await snapshot('AFTER_STALE'), before)
const submit = { recordVersion: before.main.recordVersion, idempotencyKey: key('SUBMIT-REJECT-ROUND'), remark: 'TEST第一重提轮次' }
result = await request('POST', `${path}/submit-review`, submit, 'Submit dispatch rejection round')
before = await snapshot('SUBMITTED')
const replay = await request('POST', `${path}/submit-review`, submit, 'Original submit key replays')
assert.equal(replay.replayed, true); assert.equal(String(replay.recordVersion), String(result.recordVersion))
await request('POST', `${path}/submit-review`, { ...submit, remark: 'TEST同键异载荷不得复用' }, 'Changed payload with original key is rejected', true)
await request('POST', `${path}/submit-review`, { ...submit, recordVersion: before.main.recordVersion, idempotencyKey: key('DUPLICATE-SUBMIT') }, 'New-key duplicate submit is rejected', true)
assert.deepEqual(await snapshot('AFTER_DUPLICATE_REJECTIONS'), before)
result = await request('POST', `${path}/dispatch-review`, { recordVersion: before.main.recordVersion, idempotencyKey: key('DISPATCH-REJECT'), decision: 'REJECTED', reason: 'TEST调度驳回' }, 'Dispatch reviewer rejects')
let detail = await get(path, 'Read dispatch rejection')
assert.equal(detail.reviewState, 'DISPATCH_REJECTED'); assert.equal(detail.reviews.dispatchReview.status, 'REJECTED')
result = await request('POST', `${path}/submit-review`, { recordVersion: detail.recordVersion, idempotencyKey: key('SUBMIT-LOADING-REJECT'), remark: 'TEST驳回重提' }, 'Resubmit after dispatch rejection')
result = await request('POST', `${path}/dispatch-review`, { recordVersion: result.recordVersion, idempotencyKey: key('DISPATCH-APPROVE'), decision: 'APPROVED', reason: 'TEST调度通过' }, 'Dispatch approves new round')
result = await request('POST', `${path}/loading-review`, { recordVersion: result.recordVersion, idempotencyKey: key('LOADING-REJECT'), decision: 'REJECTED', reason: 'TEST理货驳回' }, 'Loading reviewer rejects')
detail = await get(path, 'Read loading rejection')
assert.equal(detail.reviewState, 'LOADING_REJECTED'); assert.equal(detail.reviews.loadingReview.status, 'REJECTED')
const second = fixture.members[1]
result = await request('POST', `${path}/items/${encodeURIComponent(second.dispatchItemId)}/remove`, {
recordVersion: detail.recordVersion, idempotencyKey: key('REMOVE-SECOND'), reason: 'TEST零扫码先移出一票再真实重选' }, 'Remove only unpicked second member')
const removed = await snapshot('SECOND_REMOVED')
assert.equal(Number(removed.main.counts.packageCount), 1); assert.equal(removed.items.items.length, 1)
assert(removed.route.stops.every(stop => stop.destinationKey !== second.destination.key))
const candidates = await get(`${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: second.incomingNo })}`, 'Read freshly released source candidate')
const choices = candidates.candidates?.filter(row => id(row.customerId) === second.customerId && id(row.selection?.warehousePalletId) === second.palletId && row.palletNo === second.palletNo)
assert.equal(choices?.length, 1); const choice = choices[0]
assert(choice.selection && choice.sourceVersion && choice.blockReasons?.length === 0 && Number(choice.availablePackages) === 1)
result = await request('POST', `${path}/items`, { recordVersion: result.recordVersion, idempotencyKey: key('ADD-SECOND'),
cargo: [{ ...choice.selection, warehousePalletId: second.palletId, sourceVersion: choice.sourceVersion }], batchAllocations: [] }, 'Re-add exact released current source')
const added = await snapshot('SECOND_RESTORED')
assert.equal(added.items.items.length, 2); assert.equal(Number(added.main.counts.packageCount), 2)
const replacement = added.items.items.filter(row => id(row.customerId) === second.customerId && id(row.warehousePalletId) === second.palletId)
assert.equal(replacement.length, 1); second.dispatchItemId = id(replacement[0].dispatchItemId)
const nextPlate = `FT-${context.marker.slice(-12)}-R2`
const vehicle = await request('POST', `${ROOT}/master-data/vehicles`, { plateNumber: nextPlate, model: 'Box Truck', fuelConsumptionPer100Km: 12.5,
mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
remark: `${context.marker} TEST零扫码改派自有车辆` }, 'Create owned replacement TEST vehicle')
const update = { recordVersion: result.recordVersion, idempotencyKey: key('CHANGE-VEHICLE'), vehicle: { mode: 'MASTER', vehicleId: id(vehicle.id) },
driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } },
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST零扫码改车需两级重审` }
result = await request('PUT', path, update, 'Change resources before re-review')
detail = await get(path, 'Resource edit invalidates old approvals')
assert.equal(detail.reviewState, 'DRAFT'); assert.equal(id(detail.vehicleInfo.vehicle.vehicleId), id(vehicle.id))
assert.notEqual(id(vehicle.id), fixture.tripProof.vehicleId)
for (const [action, suffix] of [['submit-review', 'FINAL-SUBMIT'], ['dispatch-review', 'FINAL-DISPATCH'], ['loading-review', 'FINAL-LOADING']]) {
result = await request('POST', `${path}/${action}`, { recordVersion: result.recordVersion, idempotencyKey: key(suffix),
...(action === 'submit-review' ? { remark: 'TEST变更后重审' } : { decision: 'APPROVED', reason: 'TEST新依据重审通过' }) }, `Final new round ${action}`)
}
const final = await snapshot('REAPPROVED_ZERO_PICK')
assert.equal(final.main.stage, 'LOADING'); assert.equal(final.main.reviewState, 'APPROVED')
assert(Number(final.main.reviews.dispatchReview.reviewRound) > Number(initial.main.reviews.dispatchReview.reviewRound))
assert.equal(final.main.reviews.dispatchReview.reviewRound, final.main.reviews.loadingReview.reviewRound)
assert.deepEqual(final.stock, initial.stock, 'All zero-pick review and cargo/resource edits preserve complete inventory')
const fleet = await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`, 'Read same main updated trip resources and route')
assert.equal(fleet.trip.tripNo, fixture.tripNo); assert.equal(id(fleet.trip.vehicleId), id(vehicle.id))
assert.equal(fleet.dispatches.length, 1); assert.equal(fleet.dispatches[0].dispatchNo, fixture.dispatchNo)
Object.assign(fixture.tripProof, { marker: nextPlate, vehicleId: id(vehicle.id), vehicle: final.main.vehicleInfo.vehicle,
vehicleInfo: final.main.vehicleInfo, driver: final.main.vehicleInfo.driver, updateRequest: update, trip: fleet.trip,
stops: fleet.trip.stops, fleetRecordVersion: fleet.trip.recordVersion, recordVersion: final.main.recordVersion })
proof.membersAfterEdit = clone(fixture.members); proof.finalVehicleInfo = clone(final.main.vehicleInfo)
proof.status = 'VERIFIED'; await context.checkpoint(); return proof
}
export async function verifyReviewExtension(context, fixture, options) {
await exerciseReviewLifecycle(context, fixture, options)
return verifyDualDestination(context, fixture, options)
}
export function assertConfirmedRelabelCancellation(before, after, outbound, stockBefore, stockAfter) {
assert.equal(before.application.status, 'READY_FOR_OUTBOUND')
assert(before.application.customerConfirmedAt && before.application.outboundOrderNo)
assert.equal(after.application.relabelNo, before.application.relabelNo)
assert.equal(after.application.customerConfirmedAt, before.application.customerConfirmedAt)
assert.equal(after.application.status, 'READY_FOR_OUTBOUND')
assert.equal(after.application.deliveryMethod, before.application.deliveryMethod)
assert.equal(after.application.outboundOrderNo ?? null, null)
assert.equal(after.application.fulfillmentStatus, 'WAIT_OUTBOUND_CREATION')
assert.deepEqual(after.items, before.items)
assert.equal(outbound.outbound.status, 'CANCELLED')
assert.equal(outbound.outbound.outboundNo, before.application.outboundOrderNo)
assert.deepEqual(stockAfter, stockBefore, 'Zero-scan cancellation preserves all completed relabel stock and immutable ledger rows')
}
/** 独立第三客户三箱正式实收;不改变正常链两个客户的一箱库存基线。 */
export async function exerciseRelabelExtensions(context, fixture, { readInventory }) {
const proof = context.createdResources.dispatchWorkflowRelabel = { marker: context.marker, status: 'PREPARING',
syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false,
scenarios: [], sharedDispatches: [], nonTruckRearrangement: 'NOT_TESTED_NO_FORMAL_PUBLIC_ENTRY' }
const key = label => `${context.marker}-RL-${label}`
const api = async (method, path, body, token) => {
const response = await context.request(method, path, body, token)
const data = context.apiSuccess(MODULE, 'Formal relabel extension HTTP', response, '', OPTIONS)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return data
}
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const upload = async (path, name) => {
const response = await context.requestMultipart('POST', path, { name, type: 'image/png', content: png }, fixture.pdaToken)
const data = context.apiSuccess(MODULE, 'Upload explicitly synthetic TEST relabel evidence', response, '', OPTIONS)
assert.equal(String(response.body?.code), '200'); assert(data?.fileId); return id(data.fileId)
}
const master = await api('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.members[0].destination.warehouseId)}?tenantId=0`)
const address = master.addresses?.find(row => row.addressLine1)
assert(address && master.warehouse?.status === 'ACTIVE')
const child = Object.create(context)
child.marker = `${context.marker}-RELABEL`; child.options = { ...context.options, customerName: `TEST换标扩展-${context.marker.slice(-12)}` }
child.context = {}; child.createdResources = {}
child.request = async (...args) => context.request(...args)
await createQualifiedMerchant(child, { publishEnvironment: false })
const credentials = child.context.merchantQualificationFixture
assert(credentials?.tenantId && credentials.marker === child.marker)
proof.customerId = id(credentials.tenantId); proof.qualification = clone(child.createdResources.merchantQualificationFixture)
let session
try { session = await api('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, '') }
finally { delete child.context.merchantQualificationFixture }
assert(session?.accessToken && session.refreshToken)
const merchantToken = session.accessToken, customerId = proof.customerId
const stock = () => readInventory({ marker: context.marker, customerId, warehouseId: fixture.warehouseId })
try {
const profile = await api('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken)
assert.equal(id(profile.id), customerId)
const skuCode = `RL-${context.marker.slice(-12)}`
const merchantRoot = '/api/merchant/v1/last-leg/warehouse-transfers'
const create = { sourceLastlegOrderIds: [], operatingWarehouseId: fixture.warehouseId,
fbaWarehouseCode: master.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(master.warehouse.id),
destinationWarehouseCode: master.warehouse.warehouseCode, destinationWarehouseName: master.warehouse.warehouseName,
deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.addressLine1, address.postalCode].filter(Boolean).join(' '),
packageCount: 3, volumeCbm: 3, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19),
needRelabel: false, remark: `${context.marker} TEST三箱换标独立来货`, idempotencyKey: key('RECEIPT-CREATE'),
items: [{ skuCode, productNameZh: 'TEST三箱换标货物', packageCount: 3, quantity: 3 }] }
proof.receiptCreate = clone(create); await context.checkpoint()
let incoming = await api('POST', merchantRoot, create, merchantToken)
proof.incomingNo = incoming.transferNo; assert(proof.incomingNo)
const merchantPath = `${merchantRoot}/${encodeURIComponent(proof.incomingNo)}`
incoming = await api('POST', `${merchantPath}/submit`, undefined, merchantToken)
incoming = await api('POST', `${ROOT}/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}/review?customerId=${customerId}`,
{ decision: 'APPROVE', remark: `${context.marker} TEST三箱独立实收` })
assert.equal(incoming.status, 'WAIT_PDA_RECEIVING')
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
const task = await api('GET', pda, undefined, fixture.pdaToken)
assert.equal(Number(task.packageCount), 3); assert.equal(task.items.length, 1); assert.equal(task.cartons.length, 3)
assert.equal(task.items[0].skuCode, skuCode); assert.equal(Number(task.items[0].quantity), 3)
assert.equal(new Set(task.cartons.map(row => row.cartonMark)).size, 3)
const draft = await api('POST', `${pda}/pallets/drafts`, { idempotencyKey: key('DRAFT') }, fixture.pdaToken)
assert.equal(draft.pallets.length, 1)
const palletPath = `${pda}/pallets/${encodeURIComponent(draft.pallets[0].id)}`
const generated = await api('POST', `${palletPath}/generate`, { idempotencyKey: key('GENERATE') }, fixture.pdaToken)
const pallet = generated.pallets[0]; assert(pallet.warehousePalletId && pallet.palletNo)
await api('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key('BIND') }, fixture.pdaToken)
const counted = await api('POST', `${pda}/cartons/count`, { forecastPackageCount: 3, idempotencyKey: key('COUNT'),
boxes: task.cartons.map(carton => ({ cartonMark: carton.cartonMark, transferItemId: id(task.items[0].id), transferPalletId: id(draft.pallets[0].id),
length: 100, width: 100, height: 100, weight: 100, items: [{ skuCode, quantity: 1 }] })) }, fixture.pdaToken)
assert.equal(counted.cartons.length, 3); assert(counted.cartons.every(row => row.status === 'COUNTED' && Number(row.quantity) === 1))
assert.equal(Number(counted.pallets[0].cartonCount), 3); assert.equal(Number(counted.pallets[0].grossWeightKg), 300)
const photoId = await upload(`${pda}/measurement-photos/files`, `${context.marker}-TEST-three-boxes.png`)
incoming = await api('POST', `${pda}/palletize`, { idempotencyKey: key('PALLETIZE'), photoFileIds: [photoId] }, fixture.pdaToken)
assert.equal(incoming.status, 'WAIT_CUSTOMER_CONFIRMATION')
incoming = await api('POST', `${merchantPath}/confirm-measurement`, { idempotencyKey: key('CONFIRM-RECEIPT'), remark: context.marker }, merchantToken)
assert.equal(incoming.status, 'READY_FOR_DISPATCH')
proof.receipt = await stock()
assert.equal(proof.receipt.balances.length, 1); assert.equal(proof.receipt.positions.length, 1); assert.equal(proof.receipt.ledger.length, 1)
assert.equal(Number(proof.receipt.balances[0].availablePackages), 3)
assert.equal(id(proof.receipt.positions[0].palletId), id(pallet.warehousePalletId))
const candidates = await api('GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${fixture.warehouseId}`, undefined, merchantToken)
const sources = candidates.filter(row => id(row.inventoryBalanceId) === id(proof.receipt.balances[0].id) && row.inboundCode === proof.incomingNo)
assert.equal(sources.length, 1); const source = sources[0]
const positions = source.positions.filter(row => id(row.positionId) === id(proof.receipt.positions[0].id))
assert.equal(positions.length, 1); assert.equal(Number(positions[0].availablePackages), 3)
const resolve = code => api('POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code }, fixture.pdaToken)
let sharedTarget
for (const [index, method] of ['TRUCK', 'TRUCK', 'PICKUP'].entries()) {
const name = `${method}-${index + 1}`, scenario = { name, operationType: 'CARTON_LABEL', deliveryMethod: method }
proof.scenarios.push(scenario)
const item = { sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(positions[0].positionId),
sourceInboundOrderId: id(source.inboundOrderId), skuCode, productNameZh: 'TEST三箱换标货物', totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: `TEST-RL-${context.marker.slice(-12)}-${index + 1}`, cartonMark: task.cartons[index].cartonMark,
fbaCartonMark: `TEST-NEW-${context.marker.slice(-12)}-${index + 1}`, destinationFbaWarehouseCode: master.warehouse.warehouseCode,
operationType: 'CARTON_LABEL', skuItems: [], labelRequirement: `${context.marker} TEST换标成果` }
scenario.createBody = { customerOrderNo: `TEST-${context.marker.slice(-12)}-${name}`, operationWarehouseId: fixture.warehouseId,
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), contactName: 'TEST换标验收', contactPhone: '19900000000',
remark: context.marker, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: method, printShippingLabel: false, items: [item] }
await context.checkpoint()
const created = await api('POST', `${ROOT}/relabel-applications?customerId=${customerId}`, scenario.createBody)
scenario.relabelNo = created.application.relabelNo; assert(scenario.relabelNo)
assert.equal(created.application.status, 'PENDING_REVIEW')
assert(created.application.lastlegOrderId)
const approved = await api('PUT', `${ROOT}/orders/${encodeURIComponent(created.application.lastlegOrderId)}/relabel-review?customerId=${customerId}`,
{ status: 'PENDING_PROCESSING', remark: `${context.marker} TEST正式审核后允许PDA作业` })
assert.equal(approved.application.status, 'PENDING_PROCESSING'); scenario.approved = clone(approved)
const taskRoot = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(scenario.relabelNo)}`
let detail = await api('POST', `${taskRoot}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }, fixture.pdaToken)
assert.equal(detail.items.length, 1)
detail = await api('POST', `${taskRoot}/units`, { itemId: id(detail.items[0].itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
skuVerifications: [], expectedVersion: detail.version, idempotencyKey: key(`${name}-SCAN`) }, fixture.pdaToken)
let target
if (index === 1) target = sharedTarget
else target = await api('POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { relabelNo: scenario.relabelNo,
idempotencyKey: key(`${name}-LABEL`) }, fixture.pdaToken)
assert(target?.palletId && target.palletCode); if (index === 0) sharedTarget = target
const from = await resolve(pallet.palletNo), to = await resolve(target.palletCode)
await api('POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { relabelNo: scenario.relabelNo,
idempotencyKey: key(`${name}-MOVE`), source: { palletCode: from.palletCode, expectedVersion: from.palletVersion },
target: { palletCode: to.palletCode, expectedVersion: to.palletVersion }, cartonMarks: [item.cartonMark], reason: `${context.marker} TEST实际箱移绑` }, fixture.pdaToken)
scenario.targets = [clone(target)]
const fileId = await upload(`${taskRoot}/photos/files`, `${context.marker}-${name}-TEST.png`)
detail = await api('POST', `${taskRoot}/complete`, { photoFileIds: [fileId], expectedVersion: detail.version,
idempotencyKey: key(`${name}-COMPLETE`), remark: context.marker }, fixture.pdaToken)
assert.equal(detail.applicationStatus, 'CUSTOMER_CONFIRM_PENDING')
const confirmed = await api('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(scenario.relabelNo)}/confirm-completion`,
{ idempotencyKey: key(`${name}-CONFIRM`), remark: context.marker }, merchantToken)
assert.equal(confirmed.application.status, 'READY_FOR_OUTBOUND'); assert(confirmed.application.customerConfirmedAt)
scenario.confirmed = clone(confirmed); scenario.items = clone(confirmed.items); scenario.relabelVerified = true
scenario.destinationFbaWarehouseCode = master.warehouse.warehouseCode
await context.checkpoint()
}
proof.sharedPhysical = await resolve(sharedTarget.palletCode)
assert.equal(Number(proof.sharedPhysical.totalPackages), 2)
const pickup = proof.scenarios[2], relabelPath = `${ROOT}/relabel-applications/${encodeURIComponent(pickup.relabelNo)}`
const before = await api('GET', relabelPath), stockBefore = await stock()
assert.equal(before.application.fulfillmentStatus, 'OUTBOUND_CREATED'); assert(before.application.outboundOrderNo)
const outboundPath = `${ROOT}/outbound-orders/${encodeURIComponent(before.application.outboundOrderNo)}`
const outboundBefore = await api('GET', outboundPath)
assert(['WAIT_PICK', 'WAIT_OUTBOUND'].includes(outboundBefore.outbound.status))
proof.cancel = { status: 'INTENT', before, stockBefore, outboundBefore, body: { reason: `${context.marker} TEST已确认换标零扫码撤销本次出库安排` } }
await context.checkpoint()
await api('POST', `${outboundPath}/cancel`, proof.cancel.body)
const after = await api('GET', relabelPath), outboundAfter = await api('GET', outboundPath), stockAfter = await stock()
Object.assign(proof.cancel, { after, outboundAfter, stockAfter })
assertConfirmedRelabelCancellation(before, after, outboundAfter, stockBefore, stockAfter)
proof.cancel.status = 'VERIFIED'; await context.checkpoint()
context.assert(MODULE, 'Confirmed relabel zero-scan outbound cancellation preserves completed items, customer confirmation and all raw stock', null, true, '', OPTIONS)
await verifyRelabelSharedDispatches(context, fixture, { readInventory }, proof)
return proof
} finally {
await api('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, merchantToken)
proof.ownedMerchantSessionClosed = true; await context.checkpoint()
}
}
export async function verifyRelabelSharedDispatches(context, fixture, { readInventory }, proof) {
const customerId = proof.customerId, truck = proof.scenarios.slice(0, 2)
const stock = () => readInventory({ marker: context.marker, customerId, warehouseId: fixture.warehouseId })
const api = async (method, path, body) => {
const response = await context.request(method, path, body)
const data = context.apiSuccess(MODULE, 'Exact owned relabel shared physical selection', response, '', OPTIONS)
assert.equal(String(response.body?.code), '200'); return data
}
assert.equal(proof.cancel.status, 'VERIFIED'); assert.equal(truck.length, 2)
assert(truck.every(row => row.relabelVerified && row.deliveryMethod === 'TRUCK'))
proof.status = 'CHECKING_SHARED_NATIVE'
for (const round of [1, 2]) {
const byKey = new Map()
for (const scenario of truck) {
const page = await api('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId,
sourceStage: 'RELABEL', orderNo: scenario.relabelNo })}`)
assert.equal(page.complete, true)
for (const row of page.candidates) {
assert(truck.some(item => item.relabelNo === row.selection?.relabelNo))
const prior = byKey.get(row.sourceKey)
if (prior) assert.deepEqual(prior, row)
byKey.set(row.sourceKey, row)
}
}
const rows = [...byKey.values()]
assert.equal(rows.length, 2); assert.equal(new Set(rows.map(row => id(row.selection.warehousePalletId))).size, 1)
const createBody = nativeRelabelCreateBody(`${context.marker}-ROUND-${round}`, truck, rows, customerId, fixture.warehouseId)
const run = { round, status: 'INTENT', createBody, stockBefore: await stock() }; proof.sharedDispatches.push(run); await context.checkpoint()
run.result = await api('POST', `${ROOT}/dispatches`, createBody)
assert(run.result.dispatchNo)
const path = `${ROOT}/dispatches/${encodeURIComponent(run.result.dispatchNo)}`
run.detail = await api('GET', path); run.items = await api('GET', `${path}/items`)
assert.equal(Number(run.detail.counts.packageCount), 2); assert.equal(Number(run.detail.counts.palletCount), 1)
assert.equal(Number(run.detail.counts.pickedPackageCount), 0); assert.equal(run.items.items.length, 2)
assert.equal(new Set(run.items.items.map(row => id(row.warehousePalletId))).size, 1)
assert(run.items.items.every(row => id(row.customerId) === customerId && Number(row.scheduledPackages) === 1))
run.cancelBody = { recordVersion: run.detail.recordVersion, idempotencyKey: `${context.marker}-RL-SHARED-${round}-CANCEL`, reason: `${context.marker} TEST零扫码验证后撤销安排保留换标成果` }
await context.checkpoint(); run.cancelled = await api('POST', `${path}/cancel`, run.cancelBody)
assert.equal(run.cancelled.status, 'CANCELLED'); run.stockAfter = await stock(); assert.deepEqual(run.stockAfter, run.stockBefore)
for (const scenario of truck) {
const current = await api('GET', `${ROOT}/relabel-applications/${encodeURIComponent(scenario.relabelNo)}`)
assert.equal(current.application.status, 'READY_FOR_OUTBOUND'); assert.equal(current.application.customerConfirmedAt, scenario.confirmed.application.customerConfirmedAt)
assert.deepEqual(current.items, scenario.confirmed.items)
}
run.status = 'VERIFIED'; await context.checkpoint()
}
assert.notEqual(proof.sharedDispatches[0].result.dispatchNo, proof.sharedDispatches[1].result.dispatchNo)
context.assert(MODULE, 'Two confirmed applications share one actual pallet without double counting and can be natively rearranged after zero-pick cancellation', null, true, '', OPTIONS)
proof.status = 'VERIFIED_WITH_NON_TRUCK_REARRANGEMENT_GAP'; await context.checkpoint(); return proof
}
export async function reconcileRelabelShared(context, fixture, options, prior) {
const proof = context.createdResources.dispatchWorkflowRelabel = clone(prior.createdResources.dispatchWorkflowRelabel)
assert.equal(proof.sharedDispatches.length, 0); assert.equal(proof.cancel.status, 'VERIFIED')
assert(proof.scenarios.every(row => row.relabelVerified === true))
const read = async path => {
const response = await context.request('GET', path)
assert.equal(String(response.body?.code), '200'); return response.body.data
}
const main = await read(`${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`)
const trip = await read(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`)
assert.equal(main.stage, 'DELIVERED'); assert.equal(trip.trip.status, 'COMPLETED')
assert(trip.trip.vehicleRelease && trip.trip.driverRelease)
const currentStock = await options.readInventory({ marker: context.marker, customerId: proof.customerId, warehouseId: fixture.warehouseId })
assert.deepEqual(currentStock, proof.cancel.stockAfter)
for (const scenario of proof.scenarios) {
const current = await read(`${ROOT}/relabel-applications/${encodeURIComponent(scenario.relabelNo)}`)
assert.equal(current.application.status, 'READY_FOR_OUTBOUND'); assert.equal(current.application.customerConfirmedAt, scenario.confirmed.application.customerConfirmedAt)
assert.deepEqual(current.items, scenario.confirmed.items)
}
await verifyRelabelSharedDispatches(context, fixture, options, proof)
context.createdResources.fleetCrossCustomer = clone(prior.createdResources.fleetCrossCustomer)
return context.createdResources.fleetCrossCustomer
}
export async function verifyRelabelExtension(context, fixture, options) {
const verified = await verifyDualDestination(context, fixture, options)
try { await exerciseRelabelExtensions(context, fixture, options) }
catch (error) {
if (!context.createdResources.fleetWriteBoundary && !fixture.tripProof.closure) await finishFleetTrip(context, MODULE, fixture.tripProof)
throw error
}
return verified
}
/** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */
export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) {
assert.equal(prior.success, false)
assert.equal(prior.error, 'Native main/items/PDA retain both true customers and physical pallets')
assert.equal(prior.createdResources.dispatchWorkflowReviews.status, 'VERIFIED')
assert.equal(prior.createdResources.fleetWriteBoundary, undefined)
const old = prior.createdResources.fleetCrossCustomer, path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
assert.equal(old.dispatchNo, fixture.dispatchNo); assert.equal(old.marker, context.marker)
assert(old.requests.filter(row => row.label === 'Attach receipt to independently complete this delivery').length === 2)
assert(old.requests.every(row => row.status === 'RESPONDED'))
const get = async target => {
const response = await context.request('GET', target)
const data = context.apiSuccess(MODULE, 'Explicit terminal reconciliation read only', response, '', OPTIONS)
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data
}
const current = { trip: await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`), dispatch: await get(path),
items: await get(`${path}/items`), task: null, members: [] }
const pda = await context.request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`, undefined, fixture.pdaToken)
assert.equal(pda.http, 200); assert.equal(String(pda.body?.code), '200'); current.task = pda.body.data
assert.equal(current.dispatch.stage, 'DELIVERED'); assert.equal(current.dispatch.status, 'ACTIVE')
for (const value of [current.items, current.task]) {
assert.equal(value.dispatchNo, fixture.dispatchNo); assert.equal(value.stage, 'DELIVERED')
assert.equal(id(value.recordVersion), id(current.dispatch.recordVersion)); assert.equal(value.items.length, 2)
}
for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) assert.equal(Number(current.dispatch.counts[field]), 2)
assert.equal(current.trip.trip.tripNo, fixture.tripNo)
assert.equal(id(current.trip.trip.vehicleId), fixture.tripProof.vehicleId); assert.equal(id(current.trip.trip.driverId), fixture.tripProof.driverId)
assert.equal(current.trip.dispatches.length, 1); assert.equal(current.trip.dispatches[0].dispatchNo, fixture.dispatchNo)
const extension = context.createdResources.dispatchWorkflowExtensions = clone(prior.createdResources.dispatchWorkflowExtensions)
extension.status = 'CHECKING'; extension.explicitTerminalContinuation = true; extension.priorFailurePreserved = true
const allFeeIds = new Set()
for (const [index, member] of fixture.members.entries()) {
const oldMember = old.members[index]
assert.equal(member.customerId, oldMember.customerId); assert.equal(member.dispatchItemId, oldMember.dispatchItemId)
for (const rows of [current.items.items, current.task.items]) {
const matches = rows.filter(row => id(row.dispatchItemId) === member.dispatchItemId)
assert.equal(matches.length, 1); assert.equal(id(matches[0].customerId), member.customerId)
assert.equal(matches[0].palletNo, member.palletNo); assert.equal(Number(matches[0].pickedPackages), 1)
}
const suffix = new URLSearchParams({ customerId: member.customerId })
const outbound = await get(`${ROOT}/outbound-orders/${encodeURIComponent(oldMember.outboundNo)}?${suffix}`)
const delivery = await get(`${ROOT}/delivery-orders/${encodeURIComponent(oldMember.deliveryNo)}?${suffix}`)
assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED')
assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED')
assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length)
for (const fee of delivery.fees) {
assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id))
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
}
const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })
assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory')
current.members.push({ outbound, delivery, stock })
}
assert.deepEqual(await get(path), current.dispatch, 'Terminal state changed during reconciliation')
const verified = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo,
tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, members: clone(old.members), status: 'VERIFIED', snapshots: { FINAL: current },
continuation: 'TERMINAL_READONLY_AFTER_CONFIRMED_SIGNATURES', priorError: prior.error,
gateProof: old.gateProof, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }
extension.status = 'VERIFIED'; await context.checkpoint(); return verified
}
@@ -0,0 +1,138 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs'
import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs'
test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => {
const plan = await runDispatchExtensions(parseExtensionArguments([]), {})
assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5)
assert.equal(extensionMatrix.find(row => row.scenario === 'relabel').readiness, 'READY_FORMAL_THREE_BOX_RECEIPT')
})
test('execution requires fresh explicit TEST scope and declared synthetic load, never coerces a plan', () => {
const args = ['--execute', '--environment', 'TEST', '--run-id', 'EXT1', '--scenario', 'dual-destination', '--warehouse-id', '10',
'--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1']
assert.equal(parseExtensionArguments(args).execute, true)
for (const [from, to] of [['TEST', 'DEV'], ['EXT1', 'plan'], ['dual-destination', 'unsupported'], ['10', ''], ['1', '0']])
assert.throws(() => parseExtensionArguments(args.map(value => value === from ? to : value)))
assert.throws(() => parseExtensionArguments([...args, '--execute']))
})
test('terminal continuation requires an explicit original report hash and is never an implicit replay', () => {
assert.throws(() => parseExtensionArguments(['--scenario', 'review-terminal']))
assert.throws(() => parseExtensionArguments(['--resume-report', 'reports/old/report.json']))
const config = parseExtensionArguments(['--execute', '--environment', 'TEST', '--run-id', 'EXTCLOSE1', '--scenario', 'review-terminal',
'--warehouse-id', '10', '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1',
'--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)])
assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64))
})
test('actual group owner controls distinct cents even when server group ordering differs', () => {
const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }]
const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey,
fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) }
const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT')
assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25])
assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775)
assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL')
assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成')))
})
test('unknown customers, repeated groups and missing real required fees fail before transport', () => {
const members = [{ customerId: 'A' }, { customerId: 'B' }]
for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }],
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) {
const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) }
assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT'))
}
assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] },
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT'))
})
test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => {
const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true },
{ customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }],
tripProof: { stops: [{}, { destinationKey: 'FBA:ONE' }, { destinationKey: 'FBA:TWO' }] } }
assert.doesNotThrow(() => assertDualDestinationFixture(fixture))
for (const mutate of [f => { f.members[1].customerId = 'A' }, f => { f.members[1].destination.key = 'FBA:ONE' },
f => { f.tripProof.stops.pop() }]) { const bad = structuredClone(fixture); mutate(bad); assert.throws(() => assertDualDestinationFixture(bad)) }
})
test('fee conservation uses integer cents and rejects unsafe, negative or overprecision amounts', () => {
assert.equal(feeCents('31.25'), 3125); assert.equal(feeCents(47.75), 4775)
for (const value of [null, false, -1, '1e2', '0.001', Number.MAX_SAFE_INTEGER]) assert.throws(() => feeCents(value))
})
for (const unknown of [false, true]) test(`bounded two-actor race ${unknown ? 'preserves unknown result and stops' : 'starts both requests before either returns and conserves inventory'}`, async () => {
const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA_TOKEN', members: [{ customerId: 'A' }, { customerId: 'B' }] }
const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } },
checkpoint: async () => {}, assert: () => {} }
const acknowledgement = { recordVersion: '8', result: { confirmedLoad: { pallets: ['P1', 'P2'] } } }
const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL', measurements: ['P1', 'P2'] }, requests = [], waiting = []
const send = async (method, path, payload, token) => {
requests.push({ method, path, payload: structuredClone(payload), token })
if (method === 'GET') return { http: 200, body: { code: '200', data: { immutable: path } } }
return new Promise((resolve, reject) => {
waiting.push({ resolve, reject, token })
if (waiting.length === 2) for (const entry of waiting) {
if (entry.token) entry.resolve({ http: 200, body: { code: '200', data: { ...acknowledgement, replayed: true } } })
else if (unknown) entry.reject(Error('Unknown write outcome; preserve original key'))
else entry.resolve({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员' } })
}
})
}
const execute = () => verifyLoadingReplayRace(context, fixture, { readInventory: async scope => ({ ...scope, available: 0, picked: 1 }) }, send, body, acknowledgement)
if (unknown) await assert.rejects(execute(), /Unknown write outcome/)
else await execute()
assert.equal(waiting.length, 2)
assert.equal(requests.filter(row => row.method === 'POST').length, 2)
assert(requests.filter(row => row.method === 'POST').every(row => JSON.stringify(row.payload) === JSON.stringify(body)))
const proof = context.createdResources.dispatchWorkflowCompetition.loadingReplayRace
assert.equal(proof.status, unknown ? 'UNKNOWN_RESULT' : 'VERIFIED')
assert.equal(proof.firstExecutionRace, 'NOT_TESTED')
if (!unknown) assert.deepEqual(proof.before, proof.after)
})
test('BOL PDF evidence enforces empty/same/different appointment grouping and actual quantity conservation', () => {
const fixture = { members: [{ palletNo: 'PALLET_A', incomingNo: 'PALLET_A' }, { palletNo: 'PALLET_B', incomingNo: 'PALLET_B' }] }
const pdf = (isa, pallets, total) => `BILL OF LADING\nISA #: ${isa}\n${pallets.join('\n')}\n总箱数 / Total CTNS:\n${total}\nDriver signature`
assert.equal(assertBolPages([pdf('', ['PALLET_A', 'PALLET_B'], 2)], fixture, { appointments: [] }, 'bol-empty').totalPackages, 2)
assert.equal(assertBolPages([pdf('ISA1', ['PALLET_A', 'PALLET_B'], 2)], fixture,
{ appointments: [{ data: { confirmationNo: 'ISA1' } }] }, 'bol-same').pageCount, 1)
const appointments = { appointments: [{ data: { confirmationNo: 'ISA1' } }, { data: { confirmationNo: 'ISA2' } }] }
const pages = [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA2', ['PALLET_B'], 1)]
assert.equal(assertBolPages(pages, fixture, appointments, 'bol-different').pageCount, 2)
for (const bad of [[pdf('ISA1', ['PALLET_A'], 2), pdf('ISA2', ['PALLET_B'], 2)],
[pdf('ISA1', ['PALLET_A', 'PALLET_B'], 1), pdf('ISA2', ['PALLET_B'], 1)],
[pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]])
assert.throws(() => assertBolPages(bad, fixture, appointments, 'bol-different'))
})
test('confirmed relabel cancellation conserves customer-confirmed results, method and full inventory while clearing only outbound association', () => {
const before = { application: { relabelNo: 'RL-OWN', customerConfirmedAt: '2026-10-20T15:00:00', status: 'READY_FOR_OUTBOUND',
deliveryMethod: 'PICKUP', fulfillmentStatus: 'OUTBOUND_CREATED', outboundOrderNo: 'OB-OWN' }, items: [{ id: 'I', fbaCartonMark: 'NEW', processedPackages: 1 }] }
const after = structuredClone(before); after.application.outboundOrderNo = null; after.application.fulfillmentStatus = 'WAIT_OUTBOUND_CREATION'
const outbound = { outbound: { outboundNo: 'OB-OWN', status: 'CANCELLED' } }, stock = { positions: [{ held: 1, available: 0 }], ledger: [{ immutable: 1 }] }
assert.doesNotThrow(() => assertConfirmedRelabelCancellation(before, after, outbound, stock, structuredClone(stock)))
for (const mutate of [a => { a.application.customerConfirmedAt = null }, a => { a.application.status = 'CANCELLED' },
a => { a.application.deliveryMethod = 'TRUCK' }, a => { a.application.outboundOrderNo = 'OB-OWN' }, a => { a.items[0].processedPackages = 0 }]) {
const bad = structuredClone(after); mutate(bad)
assert.throws(() => assertConfirmedRelabelCancellation(before, bad, outbound, stock, stock))
}
assert.throws(() => assertConfirmedRelabelCancellation(before, after, outbound, stock, { ...stock, ledger: [] }))
})
for (const winner of ['PDA', 'ADMIN']) test(`first valid loading CAS race records ${winner} winner and preserves the actual PDA receipt`, async () => {
const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, checkpoint: async () => {}, assert: () => {} }
const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA', members: [{ customerId: 'A' }, { customerId: 'B' }] }
const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL_PDA', manifestHash: 'ACTUAL_HASH' }
const success = { http: 200, body: { code: '200', data: { stage: 'AFTER_LOADING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } }
const rejected = { http: 200, body: { code: 'A0409' } }, pending = []; let completed = false
const send = async (method, path, payload, token) => {
if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'AFTER_LOADING' : 'LOADING',
recordVersion: completed ? '8' : '7', counts: { packageCount: 2, pickedPackageCount: 2 } } } }
return new Promise(resolve => {
pending.push({ resolve, token, payload })
if (pending.length === 2) { completed = true; for (const request of pending) request.resolve((request.token ? 'PDA' : 'ADMIN') === winner ? success : rejected) }
})
}
const outcome = await executeFirstLoadingRace(context, fixture, { readInventory: async scope => ({ ...scope, picked: 1 }) }, send, body)
assert.equal(pending.length, 2); assert.notEqual(pending[0].payload.idempotencyKey, pending[1].payload.idempotencyKey)
assert.equal(context.createdResources.dispatchWorkflowCompetition.firstLoadingRace.winner, winner)
assert.equal(outcome.pdaResponse, winner === 'PDA' ? success : rejected)
assert.equal(Boolean(outcome.error), winner === 'ADMIN')
})
@@ -26,7 +26,7 @@ function model(fault = '', countPatch = {}) {
let native
const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId,
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
destination: { key: 'FBA:FBA' } }))
destination: { key: `FBA:${transfer.destinationWarehouseCode}` } }))
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
@@ -51,6 +51,8 @@ function model(fault = '', countPatch = {}) {
if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken)
if (path.endsWith('/warehouse-admin/10')) return response(origin)
if (path.endsWith('/warehouse-admin/20')) return response(destination)
if (path.endsWith('/warehouse-admin/21')) return response({ ...destination,
warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } })
if (path === '/api/platform/v1/iam/tenants') {
const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n)
const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark }
@@ -80,7 +82,7 @@ function model(fault = '', countPatch = {}) {
const transferNo = `IN${transfers.length}`
const itemId = `FORECAST-ITEM${transfers.length}`
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
status: 'DRAFT', packageCount: 1, pallets: [],
status: 'DRAFT', packageCount: 1, pallets: [], destinationWarehouseCode: body.destinationWarehouseCode,
items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }],
cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST',
transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] }
@@ -141,7 +143,7 @@ function model(fault = '', countPatch = {}) {
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId,
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' },
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: `FBA:${transfer.destinationWarehouseCode}` },
sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] })
}
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
@@ -152,10 +154,11 @@ function model(fault = '', countPatch = {}) {
counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {},
documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} }
trip.dispatches = [{ dispatchNo: native.dispatchNo }]
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] })
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, ...[...new Set(transfers.map(row => row.destinationWarehouseCode))]
.map(code => ({ name: code, destinationKey: `FBA:${code}` }))] })
dispatches.push(native); return response(native)
}
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) }
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) }
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
@@ -270,6 +273,26 @@ test('native Jackson Long count strings build the same complete one-main fixture
assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo))
})
test('explicit per-member destinations are read before writes and preserved in both receipts and route', async () => {
const m = model(); m.options.destinationWarehouseIds = ['20', '21']
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
assert.deepEqual(fixture.members.map(row => row.destination.key), ['FBA:FBA', 'FBA:FBA2'])
assert.deepEqual(m.trip.trip.stops.slice(1).map(row => row.destinationKey), ['FBA:FBA', 'FBA:FBA2'])
const secondDestination = m.calls.findIndex(row => row.raw.includes('/warehouse-admin/21'))
const firstWrite = m.calls.findIndex(row => row.method !== 'GET')
assert.ok(secondDestination >= 0 && secondDestination < firstWrite)
assert.deepEqual(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/merchant/v1/last-leg/warehouse-transfers')
.map(row => row.body.destinationWarehouseId), ['20', '21'])
})
test('malformed member destination scope fails without network', async () => {
for (const destinations of [[], ['20'], ['20', '21', '22'], ['21', '20'], ['20', '']]) {
const m = model(); m.options.destinationWarehouseIds = destinations
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Two explicit/)
assert.equal(m.calls.length, 0)
}
})
test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => {
for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) {
assert.equal(safeFixtureCount(value), expected)
@@ -32,11 +32,19 @@ export const preparationContract = Object.freeze({
})
/** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) {
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds,
shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) {
if (!execute) return preparationContract
if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function'
|| ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function'))
throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader')
const memberDestinations = destinationWarehouseIds ?? [destinationWarehouseId, destinationWarehouseId]
if (shipmentReferences !== undefined && (!Array.isArray(shipmentReferences) || shipmentReferences.length !== 2
|| shipmentReferences.some(value => typeof value !== 'string' || !value.trim()))) throw Error('Two explicit TEST shipment references required')
if (prepareBatchAllocations !== undefined && typeof prepareBatchAllocations !== 'function') throw Error('Appointment preparation must be callable')
if (!Array.isArray(memberDestinations) || memberDestinations.length !== 2
|| memberDestinations.some(value => !id(value)) || id(memberDestinations[0]) !== id(destinationWarehouseId))
throw new Error('Two explicit member destinations must start with the primary destination')
context.createdResources ??= {}
if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomerFixture = {
@@ -106,18 +114,23 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
try {
// Explicit IDs are re-read through the public master-data API before any fixture writes.
const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse')
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination')
const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId)
&& id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type
&& detail.warehouse.warehouseCode && detail.warehouse.warehouseName
check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS')
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true)
&& validMaster(destination, destinationWarehouseId, 'FBA'))
const address = destination.addresses?.find(row => row.addressLine1)
check('FBA destination has a real address', Boolean(address))
const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district,
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ')
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true))
const destinations = new Map()
for (const destinationId of [...new Set(memberDestinations.map(id))]) {
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationId)}?tenantId=0`, undefined, 'Verify FBA destination')
check('Each explicit destination is a public active FBA master', validMaster(destination, destinationId, 'FBA'))
const address = destination.addresses?.find(row => row.addressLine1)
check('FBA destination has a real address', Boolean(address))
destinations.set(destinationId, { destination, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.district,
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') })
}
for (const suffix of ['A', 'B']) {
const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1])
const { destination, deliveryAddress } = destinations.get(memberDestinationId)
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false }
proof.members.push(member)
// Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot.
@@ -147,11 +160,12 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken)
check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId)
const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId),
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId),
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: memberDestinationId,
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
if (shipmentReferences) createBody.fbaShipmentNo = shipmentReferences[proof.members.length - 1]
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
member.incomingNo = incoming?.transferNo
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
@@ -234,6 +248,20 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId),
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null,
remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] }
if (prepareBatchAllocations) {
proof.createDispatchRequest.batchAllocations = await prepareBatchAllocations(proof.members)
if (!Array.isArray(proof.createDispatchRequest.batchAllocations)) throw Error('Formal appointment allocations must be explicit')
for (const member of proof.members) {
const current = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
undefined, 'Refresh actual candidate after formal appointment creation')
const matches = current.candidates?.filter(row => id(row.customerId) === member.customerId && id(row.selection?.warehousePalletId) === member.palletId)
check('Appointment preparation preserves exact received physical source', matches?.length === 1 && current.complete === true
&& matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].blockReasons?.length === 0)
member.selection = matches[0].selection
}
proof.createDispatchRequest.cargo = proof.members.map(member => member.selection)
await context.checkpoint?.()
}
const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers')
check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE'
&& created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0)
@@ -47,7 +47,14 @@ export async function dispatchEvidenceContext(context) {
}
export function query(path, values) {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null || value === '') continue
if (Array.isArray(value)) {
for (const item of value) if (item !== undefined && item !== null && item !== '') params.append(key, String(item))
continue
}
params.set(key, String(value))
}
return `${path}?${params}`
}
export function commandKey(marker, namespace, action) {
@@ -112,8 +119,8 @@ export function dispatchIo(context, module, proof, namespace) {
const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false }
if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker')
const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() }
const check = (label, response, passed) => {
context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true })
const check = (label, response, passed, detail = '') => {
context.assert?.(module, label, response, passed, `${detail ? `${detail}; ` : ''}marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true })
if (!passed) throw Error(`${namespace}: ${label}`)
}
const send = async (method, path, body, label) => {
@@ -136,7 +143,8 @@ export function dispatchIo(context, module, proof, namespace) {
}
const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data }
const key = action => commandKey(context.marker, namespace, action)
return { check, send, command, key }
return { check, send, command, key,
planIds: (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) }
}
export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy }
export async function replay(io, path, body, result, label, method = 'POST') {
@@ -145,7 +153,9 @@ export async function replay(io, path, body, result, label, method = 'POST') {
return again
}
export async function candidatePage(io, warehouseId, extra = {}) {
const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选')
const planIds = extra.planIds ?? io?.planIds
const params = { warehouseId, sourceStage: 'RECEIVED', ...(Array.isArray(planIds) && planIds.length ? { planIds } : {}), ...extra }
const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, params), undefined, '读取同仓原生候选')
io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates))
return result.candidates
}
@@ -251,38 +251,45 @@ async function reusablePallets(context, warehouseId) {
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
const pallets = []
if (allowAnyExistingPallet) {
const response = await context.request('GET',
`/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response,
`warehouseId=${warehouseId}`, OPTIONS)
const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey
&& asId(item?.id) && String(item?.palletCode || '').trim())
context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0,
`warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS)
if (candidates.length === 0) return null
const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) }))
reusablePalletsByWarehouse.set(scopedCacheKey, selected)
return selected
}
for (const palletCode of REUSABLE_PALLET_CODES) {
const response = await context.request('GET',
`/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`)
const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response,
`warehouseId=${warehouseId}`, OPTIONS)
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
if (!exact) {
context.assert(MODULE, '固定称重托盘主数据完整', response, false,
`warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
return null
const missingFixed = []
if (!allowAnyExistingPallet) {
for (const palletCode of REUSABLE_PALLET_CODES) {
const response = await context.request('GET',
`/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`)
const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response,
`warehouseId=${warehouseId}`, OPTIONS)
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS)
if (!exact) {
missingFixed.push(palletCode)
continue
}
pallets.push({ id: asId(exact.id), code: palletCode })
}
pallets.push({ id: asId(exact.id), code: palletCode })
if (missingFixed.length === 0) {
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
return pallets
}
// 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。
context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失',
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`,
'fixture', { blocking: false })
}
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
return pallets
const response = await context.request('GET',
`/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response,
`warehouseId=${warehouseId}`, OPTIONS)
const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey
&& asId(item?.id) && String(item?.palletCode || '').trim())
context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0,
`warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS)
if (candidates.length === 0) return null
const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) }))
reusablePalletsByWarehouse.set(scopedCacheKey, selected)
return selected
}
function randomlyReusePalletIds(pallets, palletCount, randomValue) {
@@ -48,7 +48,7 @@ function server(fault = '', initial = false) {
if (path === `${BASE}/candidates`) {
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
}
if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) }
@@ -129,7 +129,12 @@ async function prepareDraft(context, io, candidates) {
const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken })
io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
const hidden = await candidatePage(io, candidates[0].warehouseId)
io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
io.check('草稿锁为软占用不阻断其他草稿候选', null,
candidates.every(c => hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)),
`hidden=${JSON.stringify(candidates.map(c => ({ key: c.sourceKey, reasons: hidden.filter(a => a.sourceKey === c.sourceKey).map(a => a.blockReasons) }))).slice(0, 600)}`)
context.addCoverageGap?.(MODULE, 'dispatch-occupancy', '正式排车活动占用阻断',
'草稿锁是软占用,不阻断其他草稿;正式排车占用阻断由 grouped 正式整车转换与 removeControl 回查覆盖',
'not-implemented', { blocking: false })
return draft
}
async function createRelabel(context, io, proof) {
@@ -152,8 +157,10 @@ export async function run(context) {
const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] }
try {
const fixture = context.createdResources.phase4OverseasPda
const dispatchPlanIds = () => (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String)
markerCandidates(context, [])
let io = dispatchIo(context, MODULE, proof, 'MAIN')
io.planIds = dispatchPlanIds()
const page = await candidatePage(io, fixture.overseasWarehouseId)
const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page))
if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本')
@@ -161,6 +168,7 @@ export async function run(context) {
;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c]))
context = await dispatchEvidenceContext(context)
io = dispatchIo(context, MODULE, proof, 'MAIN')
io.planIds = dispatchPlanIds()
proof.removalDispatchNo = await removeControl(context, io, selected[0])
const grouped = await verifyGroupedDispatch(context, MODULE, selected)
if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason)
@@ -429,7 +429,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128),
newSkuCode,
labelRequirement: context.marker,
cartonMark: `CM-${String(context.marker).slice(-28)}-${index + 1}`,
cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`,
fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null,
destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null,
operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL',
@@ -374,6 +374,11 @@ export async function disablePicker(context, picker, index) {
function rememberGeneratedPlans(context, plans) {
if (!Array.isArray(plans)) return
for (const plan of plans) {
const planId = id(plan?.id || plan?.planId)
if (longId(planId)) {
const ids = context.createdResources.phase4OverseasPda.generatedPlanIds ??= []
if (!ids.includes(planId)) ids.push(planId)
}
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
if (!nonBlank(inboundCode)) continue
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
@@ -1172,6 +1177,7 @@ export async function run(context) {
completedContainerFacts: [],
overseasWarehouseId: null, overseasWarehouseName: null,
generatedPalletsByInbound: {},
generatedPlanIds: [],
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
}
const merchantToken = await loginMerchantForNegativeProbe(context)
+2 -2
View File
@@ -562,8 +562,8 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin
Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI'
Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量'
Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口'
Assert-Contains $clientAgents '所有订单行均可进入计费重弹框' 'Client 独立计费重入口'
Assert-Contains $clientOrdersView 'dialog-only' 'Client 独立计费重弹框实现'
Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口'
Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现'
Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java'
# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。
@@ -0,0 +1,192 @@
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { runCrossCustomer } from './fleet-cross-customer-test-env.mjs'
import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs'
import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs'
import { discoverSecondDestination, verifyDualDestination, verifyReviewExtension, verifyCompetitionExtension, verifyFirstCompetitionExtension, verifyRelabelExtension, reconcileRelabelShared, prepareBolAppointments, verifyBolExtension, reconcileReviewTerminal, extensionMatrix } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
export function parseExtensionArguments(args) {
const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'dual-destination', warehouseId: '', destinationWarehouseId: '', pdaEnvFile: '', syntheticPalletSpaces: null, resumeReport: '', resumeReportSha256: '', pdfPython: '' }
const seen = new Set()
for (let index = 0; index < args.length; index++) {
const name = args[index]
if (['--plan', '--execute'].includes(name)) {
if (seen.has('mode')) throw Error('Duplicate mode'); seen.add('mode'); config.execute = name === '--execute'; continue
}
const field = { '--environment': 'environment', '--run-id': 'runId', '--scenario': 'scenario', '--warehouse-id': 'warehouseId',
'--destination-warehouse-id': 'destinationWarehouseId', '--pda-env-file': 'pdaEnvFile', '--synthetic-pallet-spaces': 'syntheticPalletSpaces',
'--resume-report': 'resumeReport', '--resume-report-sha256': 'resumeReportSha256', '--pdf-python': 'pdfPython' }[name]
if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported/duplicate argument')
seen.add(name); config[field] = args[++index]
}
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)) throw Error('Explicit TEST/fresh run ID required')
if (config.execute && (!['dual-destination', 'reviews', 'competition', 'first-competition', 'relabel', 'relabel-shared', 'review-terminal', 'bol-empty', 'bol-same', 'bol-different'].includes(config.scenario) || config.runId === 'plan' || !config.pdaEnvFile
|| !/^[1-9]\d*$/.test(config.warehouseId) || !/^[1-9]\d*$/.test(config.destinationWarehouseId) || config.syntheticPalletSpaces !== '1'))
throw Error('Ready scenario, explicit warehouse/destination/PDA config and synthetic pallet-spaces=1 required')
if (['review-terminal', 'relabel-shared'].includes(config.scenario) && (!config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) throw Error('Explicit frozen owned report required')
if (!['review-terminal', 'relabel-shared'].includes(config.scenario) && (config.resumeReport || config.resumeReportSha256)
&& (!config.scenario.startsWith('bol-') || !config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256)))
throw Error('Resume options require an explicit frozen owned checkpoint')
if (config.execute && config.scenario.startsWith('bol-') && !config.pdfPython) throw Error('Explicit PDF reader runtime required')
return config
}
export async function runDispatchExtensions(config, env = process.env) {
if (!config.execute) return { execute: false, environment: 'TEST', matrix: extensionMatrix,
limits: 'No deploy, SQL writes, permission edits, deletion, old manual main mutation, blind retries or uncontrolled fault injection.' }
if (env.CHONGCHONG_ENV !== 'test') throw Error('Only the explicitly loaded TEST configuration is accepted')
const bol = config.scenario.startsWith('bol-')
const relabelResume = config.scenario === 'relabel-shared'
if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 })
let prior
if (config.scenario === 'review-terminal' || relabelResume || bol && config.resumeReport) {
const sourcePath = resolve(ROOT, config.resumeReport)
const prefix = bol ? 'fleet-cross-DISPATCH20261020EXTBOL' : relabelResume ? 'fleet-cross-DISPATCH20261020EXTRELABEL' : 'fleet-cross-DISPATCH20261020EXTREV'
if (!sourcePath.startsWith(join(ROOT, 'reports', prefix))) throw Error('Only this extension-owned frozen report can be reconciled')
const bytes = readFileSync(sourcePath)
if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed')
prior = JSON.parse(bytes)
if (relabelResume) {
const proof = prior.createdResources?.dispatchWorkflowRelabel
if (prior.success !== false || prior.error !== "Expected values to be strictly equal:\n\n'S0500' !== '200'\n"
|| proof?.cancel?.status !== 'VERIFIED' || proof.sharedDispatches?.length !== 0 || proof.scenarios?.length !== 3
|| !proof.scenarios.every(row => row.relabelVerified === true) || prior.createdResources?.fleetWriteBoundary
|| prior.createdResources?.fleetTrips?.[0]?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|| prior.createdResources?.fleetCrossCustomer?.status !== 'VERIFIED') throw Error('Not the bounded owned relabel candidate read checkpoint')
} else if (bol) {
if (prior.success !== false || prior.error !== 'Expected values to be strictly equal:\n\n0 !== 1\n'
|| prior.createdResources?.dispatchWorkflowBol?.status !== 'CHECKING' || prior.createdResources.dispatchWorkflowBol.scenario !== config.scenario
|| prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer
|| prior.createdResources?.fleetWriteBoundary || prior.createdResources?.fleetTrips?.[0]?.closure)
throw Error('Not the bounded PDF long-pallet-code assertion checkpoint')
} else if (prior.success !== false || prior.error !== 'Native main/items/PDA retain both true customers and physical pallets'
|| prior.createdResources?.dispatchWorkflowReviews?.status !== 'VERIFIED' || prior.createdResources?.fleetWriteBoundary
|| prior.createdResources?.fleetTrips?.[0]?.closure) throw Error('Not the bounded known terminal-read race')
for (let number = 1; number <= prior.requestCount; number++)
if (JSON.parse(readFileSync(join(dirname(sourcePath), 'requests', `${number}.json`))).stage !== 'RESPONSE') throw Error('Prior request outcome uncertain; terminal mode forbidden')
}
const reportDirectory = join(ROOT, 'reports', `fleet-cross-${config.runId}`)
const intentPath = join(ROOT, 'reports', `dispatch-workflow-${config.runId}.intent.json`)
if (existsSync(reportDirectory) || existsSync(intentPath)) throw Error('Previous attempt exists; retain original requests and reconcile, never re-enter')
const pda = Object.fromEntries(readFileSync(config.pdaEnvFile, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)
.filter(line => /^[A-Z][A-Z0-9_]*=/.test(line)).map(line => { const separator = line.indexOf('=');
return [line.slice(0, separator), line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')] }))
if (!pda.PDA_USERNAME || !pda.PDA_PASSWORD) throw Error('Missing authorized existing overseas PDA credentials')
const files = ['scripts/dispatch-workflow-extensions-test-env.mjs', 'scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs',
'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/fleet-cross-customer-test-env.mjs',
'scripts/admin-test-env-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs',
'scripts/admin-test-env-modules/merchant-qualification-fixture.mjs', 'scripts/overseas-relabel-real-acceptance.mjs']
const seal = Object.fromEntries(files.map(path => [path, hash(readFileSync(join(ROOT, path)))]))
const stable = () => { for (const [path, expected] of Object.entries(seal)) if (hash(readFileSync(join(ROOT, path))) !== expected) throw Error(`Source drift: ${path}`) }
mkdirSync(join(ROOT, 'reports'), { recursive: true })
writeFileSync(intentPath, JSON.stringify({ runId: config.runId, scenario: config.scenario, environment: 'TEST', seal,
createdAt: new Date().toISOString(), syntheticTestMaterials: true, physicalMeasurementVerified: false, hardwareVerified: false,
originalManualMain: 'READ_ONLY_NOT_USED', resumeReportSha256: prior ? config.resumeReportSha256 : null,
midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }, null, 2), { flag: 'wx' })
const result = await runCrossCustomer(config, {
env: { ...env, DISPATCH_EXISTING_PDA_PASSWORD: pda.PDA_PASSWORD },
contextFactory: options => { const context = new AcceptanceRun(options); if (prior) context.marker = prior.marker; return context },
pickerCreate: async context => {
context.createdResources.extensionIdentity = { mode: 'EXISTING_TEST_PDA_OWNED_NEW_SESSION', accountMutation: false }
const response = await context.request('POST', '/api/auth/login', { loginId: pda.PDA_USERNAME, password: pda.PDA_PASSWORD }, '')
if (response.http !== 200 || String(response.body?.code) !== '200' || !response.body.data?.accessToken || !response.body.data.refreshToken) throw Error('Existing PDA login failed')
return { membershipId: null, password: pda.PDA_PASSWORD, token: response.body.data.accessToken, refreshToken: response.body.data.refreshToken }
},
pickerDisable: async () => { throw Error('Existing identity must never be disabled') },
prepare: async (context, options) => {
stable(); context.createdResources.extensionSourceSeal = seal
if (prior) {
const tripProof = structuredClone(prior.createdResources.fleetTrips[0])
context.createdResources.fleetTrips = [tripProof]
context.createdResources[bol ? 'explicitBolCheckpoint' : relabelResume ? 'explicitRelabelCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256,
originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false }
if (bol) {
context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments)
context.createdResources.fleetCrossCustomerFixture = structuredClone(prior.createdResources.fleetCrossCustomerFixture)
return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo,
warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof,
members: structuredClone(prior.createdResources.fleetCrossCustomerFixture.members),
loadingMeasurements: structuredClone(prior.createdResources.extensionSyntheticLoadingInput) }
}
const members = prior.createdResources.fleetCrossCustomerFixture.members.map(member => {
const match = prior.createdResources.fleetCrossCustomer.members.find(row => row.customerId === member.customerId)
if (!match) throw Error('Original receipt/member attribution missing')
return { ...structuredClone(member), ...structuredClone(match) }
})
return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo,
warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, members }
}
if (bol) {
const master = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(config.destinationWarehouseId)}?tenantId=0`)
const timezone = master.body?.data?.warehouse?.timezone
if (master.http !== 200 || String(master.body?.code) !== '200' || !/^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timezone || ''))
throw Error('BOL appointment fixtures require an existing FBA destination with an actual regional timezone before any fixture write')
}
const secondId = bol ? config.destinationWarehouseId : await discoverSecondDestination(context, config.destinationWarehouseId)
const fixture = await createFleetCrossCustomerFixture(context, { ...options, destinationWarehouseIds: [config.destinationWarehouseId, secondId],
...(bol ? { shipmentReferences: ['A', 'B'].map(suffix => `TEST-FBA-${context.marker.slice(-12)}-${suffix}`),
prepareBatchAllocations: members => prepareBolAppointments(context, members, config.scenario, config.warehouseId) } : {}) })
fixture.loadingMeasurements = { environment: 'TEST', marker: context.marker, dispatchNo: fixture.dispatchNo, syntheticTestMaterials: true,
physicalMeasurementVerified: false, hardwareVerified: false, pallets: fixture.members.map(member => ({ warehousePalletId: member.palletId,
palletSpaces: 1, evidence: [{ field: 'palletSpaces', value: 1, actual: true, sourceType: 'TEST_SYNTHETIC_LOADING_INPUT',
sourceBusinessNo: config.runId, observedAt: new Date().toISOString() }] })) }
context.createdResources.extensionSyntheticLoadingInput = fixture.loadingMeasurements
await context.checkpoint(); stable(); return fixture
},
verify: async (...args) => {
stable()
if (relabelResume) return reconcileRelabelShared(...args, prior)
if (prior && !bol) return reconcileReviewTerminal(...args, prior)
if (bol) {
const [context, fixture, options] = args
return verifyBolExtension(context, fixture, { ...options, bol: config.scenario, inspectBol: async () => {
const evidence = join(reportDirectory, 'evidence'), pdfPath = join(evidence, 'BOL.pdf'), journal = join(evidence, 'bol-http.json')
const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/prints/bol`
const record = { method: 'GET', path, stage: 'INTENT', numberOfBinaryRequests: 1 }
writeFileSync(journal, JSON.stringify(record, null, 2), { flag: 'wx' }); await context.checkpoint()
const response = await context.requestBinary('GET', path)
Object.assign(record, { stage: 'RESPONSE', http: response.http, contentType: response.contentType,
requestId: response.requestId, traceId: response.traceId, bytes: response.bytes.length, sha256: hash(response.bytes) })
writeFileSync(journal, JSON.stringify(record, null, 2))
if (response.http !== 200 || !response.contentType.includes('application/pdf') || Buffer.from(response.bytes).subarray(0, 5).toString() !== '%PDF-')
throw Error('Actual BOL endpoint did not return a PDF; preserve response metadata')
writeFileSync(pdfPath, response.bytes, { flag: 'wx' })
const result = JSON.parse(execFileSync(config.pdfPython, ['-c',
'import json,sys; from pypdf import PdfReader; r=PdfReader(sys.argv[1]); print(json.dumps({"pages":[p.extract_text() for p in r.pages]}))', pdfPath],
{ encoding: 'utf8', timeout: 30000 }))
writeFileSync(join(evidence, 'bol-pages.json'), JSON.stringify(result, null, 2), { flag: 'wx' })
context.createdResources.dispatchWorkflowBol.pdf = { path: 'evidence/BOL.pdf', ...record, pageCount: result.pages.length }
await context.checkpoint(); return result.pages
} })
}
return (config.scenario === 'reviews' ? verifyReviewExtension : config.scenario === 'competition' ? verifyCompetitionExtension
: config.scenario === 'first-competition' ? verifyFirstCompetitionExtension
: config.scenario === 'relabel' ? verifyRelabelExtension : verifyDualDestination)(...args)
},
finish: async (...args) => {
stable()
if (relabelResume) {
if (args[2]?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Original normal trip closure missing')
return args[2].closure
}
return finishFleetTrip(...args)
},
})
return { success: result.success, error: result.error, runId: config.runId, requestCount: result.requestCount, summary: result.summary,
sessionsClosed: result.sessionsClosed, scenario: config.scenario, extensionStatus: result.createdResources?.dispatchWorkflowExtensions?.status,
reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status,
competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status,
bolStatus: result.createdResources?.dispatchWorkflowBol?.status,
relabelStatus: result.createdResources?.dispatchWorkflowRelabel?.status,
report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const result = await runDispatchExtensions(parseExtensionArguments(process.argv.slice(2)))
console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1
}
+35 -16
View File
@@ -12,27 +12,42 @@ $ErrorActionPreference = 'Stop'
if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) {
throw 'local-check requires Windows and PowerShell 7 (pwsh).'
}
# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。
# 所有工作目录共用两个用户级锁;首槽保留原锁名,运行中的旧入口仍计入容量。
$stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks'
[IO.Directory]::CreateDirectory($stateDirectory) | Out-Null
$lockPath = Join-Path $stateDirectory 'heavy.lock'
$ownerPath = Join-Path $stateDirectory 'owner.json'
$resultPath = Join-Path $stateDirectory 'last-result.json'
function Read-Owner {
try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' }
$slotDefinitions = @(
[pscustomobject]@{ number = 1; lock = (Join-Path $stateDirectory 'heavy.lock'); owner = (Join-Path $stateDirectory 'owner.json'); result = (Join-Path $stateDirectory 'last-result.json') },
[pscustomobject]@{ number = 2; lock = (Join-Path $stateDirectory 'heavy-2.lock'); owner = (Join-Path $stateDirectory 'owner-2.json'); result = (Join-Path $stateDirectory 'last-result-2.json') }
)
function Read-Owner($definition) {
try { return [IO.File]::ReadAllText($definition.owner) } catch { return 'Owner information is being updated.' }
}
function Open-Slot {
try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') }
function Open-Slot($definition) {
try { return [IO.File]::Open($definition.lock, 'OpenOrCreate', 'ReadWrite', 'None') }
catch [IO.IOException] {
if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw }
return $null
}
}
function Open-AvailableSlot {
foreach ($definition in $slotDefinitions) {
$handle = Open-Slot $definition
if ($handle) { return [pscustomobject]@{ definition = $definition; handle = $handle } }
}
return $null
}
if ($Status) {
$probe = Open-Slot
if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' }
else { Write-Host 'BUSY:'; Write-Host (Read-Owner) }
if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) }
$busyCount = 0
foreach ($definition in $slotDefinitions) {
$probe = Open-Slot $definition
if ($probe) { $probe.Dispose(); Write-Host "IDLE: slot $($definition.number)/2 is free." }
else { $busyCount++; Write-Host "BUSY: slot $($definition.number)/2"; Write-Host (Read-Owner $definition) }
if (Test-Path -LiteralPath $definition.result) {
Write-Host "Last result for slot $($definition.number):"
try { Write-Host ([IO.File]::ReadAllText($definition.result)) } catch { Write-Host 'Result is being updated.' }
}
}
Write-Host "Capacity: $busyCount/2 slots occupied."
exit 0
}
if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' }
@@ -51,20 +66,24 @@ $owner = $null
$exitCode = 1
try {
$nextNotice = 0
while (-not ($slot = Open-Slot)) {
while (-not ($lease = Open-AvailableSlot)) {
if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) {
Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started."
exit 75
}
if ($timer.Elapsed.TotalSeconds -ge $nextNotice) {
Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)"
$owners = @($slotDefinitions | ForEach-Object { "slot $($_.number)/2: $(Read-Owner $_)" }) -join ' | '
Write-Host "WAITING for either shared heavy check slot: $owners"
$nextNotice = $timer.Elapsed.TotalSeconds + 15
}
Start-Sleep -Milliseconds 250
}
$owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
$slot = $lease.handle
$ownerPath = $lease.definition.owner
$resultPath = $lease.definition.result
$owner = [ordered]@{ label = $Label; pid = $PID; slot = $lease.definition.number; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
[IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress))
Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)"
Write-Host "RUNNING [$Label] in $directory (shared slot $($lease.definition.number)/2; timeout ${TimeoutSeconds}s)"
# Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。
if (-not ('LocalCheckJob' -as [type])) {
+21 -9
View File
@@ -34,7 +34,7 @@ async function until(check, message) {
assert.fail(message)
}
test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => {
test('local-check bounds different directories to two slots, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 120000 }, async t => {
const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-'))
const processes = []
const start = (...args) => { const result = run(state, ...args); processes.push(result); return result }
@@ -46,19 +46,31 @@ test('local-check serializes different directories, handles failures and recover
assert.match(result.output, /"2","--max-old-space-size=2048"/)
assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/)
})
await t.test('a running check excludes another directory and queue timeout starts nothing', async () => {
const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)'])
await until(() => first.output().includes('HOLDER_READY'), first.output())
await t.test('two directories run together; a third times out or waits until one lease is released', async () => {
const releaseFirst = path.join(state, 'release-first'), releaseSecond = path.join(state, 'release-second')
const hold = (file, label) => `const fs=require('fs');console.log(${JSON.stringify(label)});const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(file)}))clearInterval(timer)},50)`
const first = start([process.execPath, '-e', hold(releaseFirst, 'FIRST_READY')], { timeout: 30 })
await until(() => first.output().includes('FIRST_READY'), first.output())
const second = start([process.execPath, '-e', hold(releaseSecond, 'SECOND_READY')], { cwd: path.dirname(launcher), timeout: 30 })
await until(() => second.output().includes('SECOND_READY'), second.output())
assert.equal(first.child.exitCode, null)
const owners = await Promise.all(['owner.json', 'owner-2.json'].map(name => readFile(path.join(state, 'Chongchong', 'LocalChecks', name), 'utf8').then(JSON.parse)))
assert.deepEqual(owners.map(owner => owner.slot).sort(), [1, 2])
assert.equal(new Set(owners.map(owner => owner.directory)).size, 2)
const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done
assert.equal(timedOut.code, 75, timedOut.output)
assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/)
const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) })
await until(() => second.output().includes('WAITING'), second.output())
assert.doesNotMatch(second.output(), /SECOND_READY/)
const third = start([process.execPath, '-e', 'console.log("THIRD_READY")'], { cwd: state })
await until(() => third.output().includes('WAITING'), third.output())
assert.doesNotMatch(third.output(), /THIRD_READY/)
await writeFile(releaseFirst, '')
assert.equal((await first.done).code, 0)
const result = await second.done
const result = await third.done
assert.equal(result.code, 0, result.output)
assert.match(result.output, /SECOND_READY/)
assert.match(result.output, /THIRD_READY/)
assert.equal(second.child.exitCode, null)
await writeFile(releaseSecond, '')
assert.equal((await second.done).code, 0)
})
await t.test('execution timeout kills descendants and frees the slot', async () => {
const pidFile = path.join(state, 'descendant.pid')
@@ -57,6 +57,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'自有业务照片按平台0、原排车及照片分类复合归属;上传前记录本域命令意图,无远端幂等键且未知停写,不自动重传;当前及历史引用校验后只读元数据和内容'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'排车车辆及司机证据通过File内部令牌入口按平台0、证据类别和排车ID复合核验;POST上传无远端幂等键,禁止自动重试,GET元数据及有界内容只读,不借文件上传推进排车状态'),
@@ -123,7 +123,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta
}
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort()
assert.equal(reviewed.length, 29)
assert.equal(reviewed.length, 30)
assert.deepEqual(discovered.sort(), reviewed)
})
@@ -139,6 +139,19 @@ test('every reviewed synchronous client has a complete and exclusive executable
}
})
test('owned business photos use bounded File transport with no automatic upload retry', async () => {
const file = 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java'
const reviewed = SYNCHRONOUS_CLIENTS.find(client => client.path === file)
assert.equal(reviewed?.target, 'file-service')
assert.equal(reviewed?.access, 'READ_WRITE')
assert.equal(reviewed?.idempotency, 'MIXED')
assert.deepEqual(reviewed?.contexts, ['REQUEST'])
const source = await readFile(path.join(root, file), 'utf8')
assert.match(source, /factory\.build\(builder,baseUrl,properties\.getCustomerVisitFile\(\)\)/)
assert.match(source, /\/internal\/file\/v1\/business-files\/0\/\{type\}\/\{dispatch\}/)
assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i)
})
test('IAM customer dimension Feign is reachable only from the background relay', async () => {
const references = []
for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) {