This commit is contained in:
2026-10-20 16:45:04 +08:00
parent 0ff1eefff6
commit 0dd9c8db07
44 changed files with 889 additions and 93 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)
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
排车单号使用`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 数据不倒推历史归属,匹配版本按新原生样本验收。 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 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='排车单号每日独立原子流水';
@@ -1865,6 +1865,12 @@ CREATE TABLE ll_order_no_daily_sequence (
PRIMARY KEY (business_date) PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
CREATE TABLE 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_operation_log ( CREATE TABLE ll_order_operation_log (
id bigint NOT NULL COMMENT '操作记录主键', id bigint NOT NULL COMMENT '操作记录主键',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
@@ -4921,7 +4927,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK ( CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL) (currency IS NULL AND total_amount IS NULL)
OR (currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0), CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
+1
View File
@@ -85,6 +85,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 报价/统计:`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-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 - 在途交接:`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,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。 既有预约执行与改量只经 `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`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 `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事务;客户单据已经开工时不允许通过改派覆盖执行事实。 车次关联客户排车单新增 `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。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。 2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -9,6 +9,13 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; 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();
test('selfoperated analytics is independent of customer outbound and inventory slices', () => { test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze(); const report = analyze();
@@ -42,13 +49,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'); const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3); assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.'))); 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))) { 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.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.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.') assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false); && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.')); && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0); assert.ok(dispatchEdges.length > 0);
@@ -58,13 +65,33 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway' && edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' || 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'); '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.')); 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.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'); 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}`)));
});
function analyze(sourceSet = 'main') { function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot, cwd: moduleRoot,
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.springframework.stereotype.Component;
/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */
@Component
public class DispatchDetailReadAdapter implements DispatchDetailReader {
private final DispatchAggregateService dispatch;
/** 复用唯一原排车详情提供者,不建立第二套查询规则。 */
public DispatchDetailReadAdapter(DispatchAggregateService dispatch) {
this.dispatch = dispatch;
}
/** 原单资格与可见性仍由原应用服务核对。 */
@Override
public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) {
return dispatch.detail(actor, dispatchNo);
}
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.dispatch.port;
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
public interface DispatchDetailReader {
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
}
@@ -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 org.springframework.jdbc.core.JdbcTemplate;
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 JdbcTemplate jdbc;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
@@ -31,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service @Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -47,7 +49,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json; private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership; @jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */ /** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -268,7 +270,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity); var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get(); if (replay.isPresent()) return replay.get();
long id=IdWorker.getId(); 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, var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(), Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark())); 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);
}
}
@@ -11,7 +11,8 @@ import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set; import java.util.Set;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** File保存照片,本域另行授权原排车及分类引用。 */ /** File保存照片,本域另行授权原排车及分类引用。 */
@Component @Component
@@ -76,6 +77,10 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false; byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true; for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
} }
/** 基础设施错误不反向依赖应用Service,保持单向包依赖。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 不明确的文件结果保留为业务冲突,由上传账本记录。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
public record Envelope(String code,Metadata data) {} public record Envelope(String code,Metadata data) {}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {} public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
} }
@@ -1,6 +1,5 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper; package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
@@ -9,7 +8,10 @@ import java.util.List;
@Mapper @Mapper
public interface OwnedBusinessQueryMapper { public interface OwnedBusinessQueryMapper {
String SOURCE=""" String SOURCE="""
SELECT d.*,o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode, SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version, o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at 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 FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
@@ -38,8 +40,8 @@ public interface OwnedBusinessQueryMapper {
@Select(SOURCE+" WHERE d.dispatch_no=#{number}") @Select(SOURCE+" WHERE d.dispatch_no=#{number}")
Source find(String number); Source find(String number);
/** 页内全部货物一次读取,各客户保持原归属。 */ /** 页内全部货物一次读取,各客户保持原归属。 */
@Select("<script>SELECT * FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>") @Select("<script>SELECT id,dispatch_order_id,warehouse_pallet_id,customer_id,customer_name,source_key,order_no,inbound_code,pallet_no,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 IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
List<DispatchItem> cargo(@Param("ids") List<Long> ids); List<CargoRow> cargo(@Param("ids") List<Long> ids);
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */ /** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
@Select(""" @Select("""
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no, <script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
@@ -1,6 +1,5 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model; package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -14,11 +13,22 @@ public final class OwnedBusinessRows {
private String resourceMode,vehicleOwnership,loadingCompletionHash; private String resourceMode,vehicleOwnership,loadingCompletionHash;
private LocalDateTime ownershipCapturedAt,loadingCompletedAt; private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
} }
@Data public static class Source extends DispatchAggregate { @Data public static class Source {
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
private LocalDateTime plannedDepartureAt,loadingCompletedAt,departedAt,deliveryCompletedAt;
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash; private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
private Long ownedVehicleId,ownedVehicleRecordVersion; private Long ownedVehicleId,ownedVehicleRecordVersion;
private LocalDateTime ownedLoadingCompletedAt; private LocalDateTime ownedLoadingCompletedAt;
} }
/** 原表货物只读字段;不是可写排车实体,不复制来源状态机。 */
@Data public static class CargoRow {
private Long id,dispatchOrderId,warehousePalletId;
private String customerId,customerName,sourceKey,orderNo,inboundCode,palletNo;
private String destinationType,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,deliveryAddress,postalCode;
private Integer packageCount;
}
@Data public static class Record { @Data public static class Record {
private Long id,dispatchId,recordVersion; private Long id,dispatchId,recordVersion;
private String currency,arrivalsJson,photosJson,createdBy,updatedBy; private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
@@ -4,7 +4,6 @@ import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper; import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; 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.Record;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@@ -26,7 +25,7 @@ public class OwnedBusinessRepository {
/** 读取唯一原排车身份及归属证据。 */ /** 读取唯一原排车身份及归属证据。 */
public Source source(String number){return queries.find(number);} public Source source(String number){return queries.find(number);}
/** 页内货物批量查询。 */ /** 页内货物批量查询。 */
public List<DispatchItem> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);} public List<CargoRow> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
/** 页内实际预约分配批量查询。 */ /** 页内实际预约分配批量查询。 */
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);} public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
/** GET只读,无记录保留虚拟0版本。 */ /** GET只读,无记录保留虚拟0版本。 */
@@ -30,6 +30,7 @@ public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapt
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode); var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
if("MASTER".equals(resourceMode)) { if("MASTER".equals(resourceMode)) {
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在")); var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
if(vehicle.id()!=vehicleId.longValue())throw conflict("车辆归属提供者返回的身份不一致");
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效"); if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确"); if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion()); row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service; package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
@@ -27,7 +27,7 @@ public class OwnedBusinessProjection {
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */ /** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);} public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */ /** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
public String destinationKey(DispatchItem item) { public String destinationKey(CargoRow item) {
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(), String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key; if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
@@ -44,7 +44,7 @@ public class OwnedBusinessProjection {
return ids.size(); return ids.size();
} }
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */ /** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<DispatchItem> cargo,List<Batch> batches,Actor actor) { public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
var resources=codec.read(source.getResourcesJson(),Resources.class); var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
@@ -5,7 +5,7 @@ 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.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,10 +22,10 @@ public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository; private final OwnedBusinessRepository repository;
private final OwnedBusinessProjection projection; private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec; private final OwnedBusinessSupport codec;
private final DispatchAggregateService dispatch; private final DispatchDetailReader dispatch;
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */ /** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection, public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,DispatchAggregateService dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;} OwnedBusinessSupport codec,DispatchDetailReader dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */ /** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) { public ListResult list(Actor actor,Query query) {
@@ -60,7 +60,19 @@ public class OwnedBusinessRecordService {
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values()))); row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,"保存"+slot.name()+"照片"); return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot));
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
} }
/** 照片框也参与摘要,不能换URL框位复用原键。 */ /** 照片框也参与摘要,不能换URL框位复用原键。 */
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {} public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.fleet.trip;
/** 日历专用只读查询投影,真实排车号不属于车次持久化字段。 */
public class FleetTripCalendarRow extends FleetTripEntity {
private String dispatchNo;
/** 返回关联整车主单的业务号。 */
public String getDispatchNo() { return dispatchNo; }
/** 仅供日历SQL结果映射,不写入车次表。 */
public void setDispatchNo(String dispatchNo) { this.dispatchNo = dispatchNo; }
}
@@ -11,10 +11,10 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.*; import java.time.*;
import java.util.*; import java.util.*;
/** Read-only transport plans. A trip is never evidence of a receiving appointment confirmation. */ /** 排车日历沿真实整车主单读取运输时间,不代表已取得目的仓收货确认。 */
@Service @Service
public class FleetTripCalendarService { public class FleetTripCalendarService {
public record Entry(String sourceType, String tripNo, String status, String originWarehouseId, public record Entry(String sourceType, String dispatchNo, String status, String originWarehouseId,
String originWarehouseCode, String originWarehouseName, String originCountryCode, String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
@@ -31,12 +31,13 @@ public class FleetTripCalendarService {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token; this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
} }
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public Result calendar(Instant from, Instant to) { public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0) if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次过多,请缩小查询日期范围"); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of()); if (rows.isEmpty()) return new Result(List.of(),List.of());
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids); var metadata=metadata(ids);
@@ -60,7 +61,7 @@ public class FleetTripCalendarService {
throw new IllegalArgumentException("incomplete route"); throw new IllegalArgumentException("incomplete route");
stops=List.copyOf(stops); stops=List.copyOf(stops);
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
var entry=new Entry("TRIP_PLAN",row.getTripNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons)); instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
@@ -7,17 +7,18 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> { public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */ /** 日历只读真实整车主单的出库派送,一单一条;独立车次及取消主单不进入排车日历。 */
@Select(""" @Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at, SELECT d.dispatch_no,t.trip_no,t.status,t.warehouse_id,t.warehouse_code,t.warehouse_name,t.planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json t.expected_driver_end_at,t.departed_at,t.completed_at,t.stops_json
FROM ll_fleet_trip FROM ll_dispatch_order d JOIN ll_fleet_trip t ON t.id=d.trip_id
WHERE trip_purpose='DELIVERY' AND ( WHERE d.status='ACTIVE' AND d.dispatch_no IS NOT NULL AND TRIM(d.dispatch_no)<>''
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to}) AND t.trip_purpose='DELIVERY' AND (
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})) (t.departed_at IS NOT NULL AND t.departed_at>=#{from} AND t.departed_at<#{to})
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001 OR (t.departed_at IS NULL AND t.planned_departure_at>=#{from} AND t.planned_departure_at<#{to}))
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""") """)
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo); FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -18,13 +18,14 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/fleet/trips") @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetTripController { public class PlatformFleetTripController {
@jakarta.annotation.Resource private FleetTripCalendarService calendar; @jakarta.annotation.Resource private FleetTripCalendarService calendar;
/** 预约排车日历沿用真实排车列表读取权限,不要求已退出菜单的车次管理权限。 */
@GetMapping("/calendar") @GetMapping("/calendar")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam java.time.Instant from,@RequestParam java.time.Instant to) { @RequestParam java.time.Instant from,@RequestParam java.time.Instant to) {
operator(user); operator(user);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取排车日历");
return ApiResponse.success(calendar.calendar(from,to)); return ApiResponse.success(calendar.calendar(from,to));
} }
@@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
PRIMARY KEY(business_date) PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; ) 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 COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order ( CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID', id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -4593,7 +4599,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK ( CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL) (currency IS NULL AND total_amount IS NULL)
OR (currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0), CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
@@ -48,13 +48,14 @@ paths:
available: {type: boolean} available: {type: boolean}
/api/platform/v1/last-leg/fleet/trips/calendar: /api/platform/v1/last-leg/fleet/trips/calendar:
get: get:
summary: 读取美国车次用车日历来源 summary: 读取美国真实整车排车日历来源
description: >- description: >-
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天; 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:overseas-warehouse:dispatch:list,与排车列表及详情一致。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。 该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。 仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。 最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。 仅关联ll_dispatch_order中ACTIVE真实主单,返回dispatchNo;独立旧车次和取消排车主单排除,一张排车主单一条。
保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认;日历跳排车详情,不使用内部车次号拼排车路径。
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。 已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。 一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。
parameters: parameters:
@@ -358,10 +359,10 @@ components:
schemas: schemas:
TripCalendarEntry: TripCalendarEntry:
type: object type: object
required: [sourceType, tripNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
properties: properties:
sourceType: {type: string, enum: [TRIP_PLAN]} sourceType: {type: string, enum: [DISPATCH]}
tripNo: {type: string} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}
originWarehouseId: {type: string, nullable: true} originWarehouseId: {type: string, nullable: true}
originWarehouseCode: {type: string, nullable: true} originWarehouseCode: {type: string, nullable: true}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
/** 运营组合读取必须保留原详情提供者的身份和拒绝语义。 */
class DispatchDetailReadAdapterTest {
/** 不吞原排车资格失败,不切换操作者或降级返回空详情。 */
@Test
void preservesOriginalIdentityAndReadRejection() {
var original = mock(DispatchAggregateService.class);
var actor = new DispatchContracts.Actor("owned-business-reader", DispatchTypes.ActorMode.PLATFORM, null);
var failure = new IllegalArgumentException("原单不可读取");
when(original.detail(actor, "DS-OWNED-READ")).thenThrow(failure);
var adapter = new DispatchDetailReadAdapter(original);
var actual = assertThrows(IllegalArgumentException.class, () -> adapter.detail(actor, "DS-OWNED-READ"));
org.junit.jupiter.api.Assertions.assertSame(failure, actual);
verify(original).detail(actor, "DS-OWNED-READ");
verifyNoMoreInteractions(original);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.dispatch.repository;
import org.junit.jupiter.api.Test;
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;
Fixture() {
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 proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
sequence=(DispatchNumberSequence)proxy.getProxy();
}
}
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() {
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() {
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() {
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);
}
}
@@ -536,6 +536,9 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository, service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, 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); 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,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); 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);
}
}
}
@@ -1,6 +1,6 @@
package com.baiyue.lastleg.fleet.ownedbusiness; package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
@@ -19,7 +19,7 @@ final class OwnedBusinessFixtures {
static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));} static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));}
/** 装车完成但未发车,证明不依赖DEPART历史。 */ /** 装车完成但未发车,证明不依赖DEPART历史。 */
static Source source() { static Source source() {
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setTripId(2L);row.setStatus("ACTIVE");row.setStage("AFTER_LOADING"); var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓"); row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00")); row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt()); row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());
@@ -35,12 +35,12 @@ final class OwnedBusinessFixtures {
""");return row; """);return row;
} }
/** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */ /** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */
static List<DispatchItem> cargo() { static List<CargoRow> cargo() {
var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second); var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second);
} }
/** 来源事实的目的地键与正式排车提供者一致。 */ /** 来源事实的目的地键与正式排车提供者一致。 */
private static DispatchItem item(long id,String customer,String inbound) { private static CargoRow item(long id,String customer,String inbound) {
var i=new DispatchItem();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer); var i=new CargoRow();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer);
i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31"); i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31");
i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i; i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i;
} }
@@ -33,6 +33,15 @@ class OwnedBusinessOwnershipTest {
assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership()); assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());
assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles); assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles);
} }
/** MASTER只是档案选择,不代表车辆自有,更不代表货源自有。 */
@Test void masterVehicleCanBeDispatchedAndMustKeepThatOwnership() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("DISPATCHED",4)));
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());assertEquals("MASTER",saved.getValue().getResourceMode());
assertEquals(4L,saved.getValue().getVehicleRecordVersion());
}
/** 重放沿原证据,不读取后来可能变化的车辆归属。 */ /** 重放沿原证据,不读取后来可能变化的车辆归属。 */
@Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() { @Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership(); var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership();
@@ -5,7 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*; import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
@@ -41,7 +41,7 @@ class OwnedBusinessPersistenceTest {
jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds); jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);
jdbc.execute(""" jdbc.execute("""
CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32), CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32),
stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3), stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),departed_at DATETIME(3),delivery_completed_at DATETIME(3),
loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT, loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT,
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128)) vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128))
"""); """);
@@ -108,6 +108,19 @@ class OwnedBusinessPersistenceTest {
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class)); assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class)); assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
} }
/** 真实基线CHECK必须拒绝SQL三值逻辑的UNKNOWN漏洞,同时保留未登记和明确零。 */
@Test void moneyCheckRejectsNullCurrencyWithAmountAndAcceptsNullPairAndZero() {
var actor=OwnedBusinessFixtures.actor();
service.save(actor,"DS-OWN-ONE",request("money-null-pair",0,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
()->jdbc.update("UPDATE ll_fleet_owned_business_record SET currency=NULL,total_amount=1 WHERE dispatch_id=1"));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
service.save(actor,"DS-OWN-ONE",request("money-zero-value",1,new Money("CNY","0"),null,null,List.of()));
assertEquals("CNY",repository.record(1).getCurrency());assertEquals(0,repository.record(1).getTotalAmount().signum());
service.save(actor,"DS-OWN-ONE",request("money-clear-again",2,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
}
/** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */ /** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */
@Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() { @Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() {
var first=request("first-operation",0,new Money("USD","0"),null,null,List.of()); var first=request("first-operation",0,new Money("USD","0"),null,null,List.of());
@@ -125,10 +138,31 @@ class OwnedBusinessPersistenceTest {
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
} }
/** 真绑定CAS、原键重放及替换后的历史引用均沿真实4表,不改变装车事实。 */
@Test void photoBindingKeepsCurrentAndHistoricalAuthorizationSeparate() {
var first=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","9","TEST-one.png","image/png","9","2026-10-20T07:30:00",null);
var original=new BindPhoto("0","bind-original","9");
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
assertEquals("保存装车现场照片",repository.revision(1,1).getReason());
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement);
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
assertEquals("保存装车现场照片",repository.revision(1,2).getReason());
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
assertThrows(BizException.class,()->photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,null));
assertArrayEquals(new byte[]{1,2},photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,1L).content());
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
}
/** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */ /** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */
@Test void historyMergesOriginalDispatchWithoutDuplicatingIt() { @Test void historyMergesOriginalDispatchWithoutDuplicatingIt() {
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of())); service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of()));
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class)); var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20); var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20);
assertEquals(2,page.total());assertEquals(2,page.records().size()); assertEquals(2,page.total());assertEquals(2,page.records().size());
var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow(); var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
@@ -139,7 +173,7 @@ class OwnedBusinessPersistenceTest {
} }
/** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */ /** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */
@Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() { @Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() {
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class)); var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9")); var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9"));
assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount()); assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount());
assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount()); assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount());
@@ -187,6 +221,8 @@ class OwnedBusinessPersistenceTest {
var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true); var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy(); proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
} }
/** 本机内存数据库用后关闭,避免全量回归积累DB_CLOSE_DELAY实例。 */
@AfterEach void closeDatabase(){if(jdbc!=null)jdbc.execute("SHUTDOWN");}
/** 每个请求保留原附加版本和明确量纲。 */ /** 每个请求保留原附加版本和明确量纲。 */
private SaveOperations request(String key,long version,Money money,String distance,String fuel,List<Arrival> arrivals) { private SaveOperations request(String key,long version,Money money,String distance,String fuel,List<Arrival> arrivals) {
return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录"); return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录");
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
@@ -21,7 +21,7 @@ class OwnedBusinessQueryTest {
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L); var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L);
var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L); var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L);
when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source())); when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source()));
@@ -37,14 +37,14 @@ class OwnedBusinessQueryTest {
@Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() { @Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source(); var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source();
source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source); source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source);
var dispatch=mock(DispatchAggregateService.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch); var dispatch=mock(DispatchDetailReader.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE")); assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE"));
verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion()); verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion());
} }
/** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */ /** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */
@Test void stageAndFrozenOwnershipFactsAreBothRequired() { @Test void stageAndFrozenOwnershipFactsAreBothRequired() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source); var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source);
source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
@@ -11,11 +11,16 @@ import java.util.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class FleetTripCalendarMapperTest { class FleetTripCalendarMapperTest {
/** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */ /** 测试中为每个运输资源建立唯一整车主单,独立旧车次始终不绑定主单。 */
private void bindDispatches(JdbcTemplate jdbc) {
jdbc.execute("INSERT INTO ll_dispatch_order(dispatch_no,trip_id,status) SELECT CONCAT('DS-',t.trip_no),t.id,'ACTIVE' FROM ll_fleet_trip t WHERE t.trip_no<>'TR-UNBOUND' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_order d WHERE d.trip_id=t.id)");
}
/** 验证真实主单关系、计划/实发半开区间及取货排除,不把资源车次号当排车号。 */
@Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception { @Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds); var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,trip_no VARCHAR(64) UNIQUE,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
jdbc.execute("CREATE TABLE ll_dispatch_order(dispatch_no VARCHAR(80) PRIMARY KEY,trip_id BIGINT UNIQUE,status VARCHAR(32))");
var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1); var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1);
String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')"; String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')";
jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to); jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to);
@@ -26,11 +31,17 @@ class FleetTripCalendarMapperTest {
jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to); jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'"); jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'");
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1)); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1));
bindDispatches(jdbc);
jdbc.update(insert,"TR-UNBOUND","PLANNED",from,to);
jdbc.update(insert,"TR-CANCELLED-DISPATCH","PLANNED",from,to);
bindDispatches(jdbc);
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE dispatch_no='DS-TR-CANCELLED-DISPATCH'");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var rows=mapper.calendar(from,to); var rows=mapper.calendar(from,to);
assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST"); assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST");
assertThat(rows).extracting(FleetTripCalendarRow::getDispatchNo).containsExactly("DS-TR-FROM","DS-TR-LAST");
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED"); assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from); assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
// The actual month is authoritative even when the original plan is outside this window. // The actual month is authoritative even when the original plan is outside this window.
@@ -40,6 +51,7 @@ class FleetTripCalendarMapperTest {
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT"); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to); jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM"); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo) assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST"); .containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN"); jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
@@ -47,6 +59,7 @@ class FleetTripCalendarMapperTest {
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST"); .containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to}); var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
jdbc.batchUpdate(insert,many); jdbc.batchUpdate(insert,many);
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001); assertThat(mapper.calendar(from,to)).hasSize(2001);
} }
} }
@@ -15,8 +15,8 @@ class FleetTripCalendarServiceTest {
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test"); final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44)); final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
FleetTripEntity trip(String no,Long warehouse) { FleetTripCalendarRow trip(String no,Long warehouse) {
var row=new FleetTripEntity();row.setTripNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED"); var row=new FleetTripCalendarRow();row.setTripNo("INTERNAL-"+no);row.setDispatchNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED");
row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC)); row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC));
row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2)); row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2));
row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row; row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row;
@@ -44,13 +44,13 @@ class FleetTripCalendarServiceTest {
metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L)); metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L));
var result=service.calendar(from,to); var result=service.calendar(from,to);
assertThat(result.records()).singleElement().satisfies(row->{ assertThat(result.records()).singleElement().satisfies(row->{
assertThat(row.sourceType()).isEqualTo("TRIP_PLAN");assertThat(row.tripNo()).isEqualTo("TR-US"); assertThat(row.sourceType()).isEqualTo("DISPATCH");assertThat(row.dispatchNo()).isEqualTo("TR-US");
assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE"); assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE");
assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600)); assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600));
assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull(); assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull();
assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty(); assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty();
}); });
assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::tripNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN"); assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::dispatchNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN");
assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull(); assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull();
assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN"); assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN");
@@ -78,7 +78,7 @@ class FleetTripCalendarServiceTest {
} }
@Test void endpointUsesExistingReadPermission() throws Exception { @Test void endpointUsesExistingReadPermission() throws Exception {
var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class); var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class);
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:trip:list')"); assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:overseas-warehouse:dispatch:list')");
} }
@Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() { @Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L))); when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L)));
@@ -98,13 +98,13 @@ class FleetTripCalendarServiceTest {
org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar); org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar);
assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class);
for (var user:List.of( for (var user:List.of(
new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")))) new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"))))
assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(calendar); verifyNoInteractions(calendar);
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:fleet:trip:list")); var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to); controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
} }
} }
@@ -56,6 +56,31 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
], ],
"reviewedClassDependencies": [ "reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
"reason": "自有业务组合原排车公开只读详情,不复制履约主模型或引用实体。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection",
"target": "com.baiyue.lastleg.dispatch.port.DispatchContracts",
"reason": "从原装车确认与资源公开快照计算真实物理托数,仅消费不可变事实,不写原排车。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchContracts",
"reason": "按可信平台身份读取原排车公开快照,核对原装车归属;不访问主流程持久化实体。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchTypes",
"reason": "原排车只读详情端口需要可信平台ActorMode,不接受浏览器提供的执行身份。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchDetailReader",
"reason": "运营视图通过只读应用端口组合原详情;不直接调用原Service或Mapper,不开放其他消费者。"
},
{ {
"source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository",
"target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine",
+6
View File
@@ -273,6 +273,12 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。 `--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,二者分别留证;不把文字检查作为视觉验收。 `--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夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。 BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本轮一箱一托来源映射守恒,不声称PDF完整展示托盘码。若本轮在该显示断言处停止且尚未开始履约,可显式传原 `--resume-report` 和 `--resume-report-sha256` 到同一BOL场景、新runId;仅接受原READY夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。
+3 -1
View File
@@ -6,6 +6,8 @@
其BOL扩展通过可选`shipmentReferences`和`prepareBatchAllocations`为本轮实收来源提供显式TEST货件参考及正式预约份额;分配准备后重新读取候选当前版本,默认夹具不创建预约。`bol-empty/same/different` 核验真实PDF页数、约号、逐托唯一出现及箱数守恒,打印前后原始库存和主单快照完全不变;实际二进制请求另存只读账本。 其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` 验证前置缺失、余额不足、请求前持久化及未知结果停写。 `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。 `phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
@@ -477,7 +479,7 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID;真实 HTTP 依赖 PDA 链产生工作事项。 - `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID;真实 HTTP 依赖 PDA 链产生工作事项。
- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次;2026-10-20 空参 HTTP 返回 `B0001`,已按非阻断缺口记录。 - `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次;2026-10-20 空参 HTTP 返回 `B0001`,已按非阻断缺口记录。
- `fleet-management` 只读(车次、出车记录、车队报价、拼车需求)已通过真实 HTTP;车次写命令仍需要本轮排车、车辆与司机资源。 - `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 上传、发布和停发需人工准备有效安装包。 - `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。
- Client 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。 - Client 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs' 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 ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
@@ -8,7 +11,7 @@ const id = value => String(value ?? '')
export const extensionMatrix = Object.freeze([ 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: '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: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' },
{ scenario: 'relabel', scope: 'Confirmed relabel cancellation/rearrangement and multiple applications sharing one physical pallet', readiness: 'REQUIRES_FORMAL_RELABEL_FIXTURE' }, { 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: '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' }, { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' },
]) ])
@@ -87,7 +90,7 @@ export async function verifyDualDestination(context, fixture, options) {
dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false,
hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] }
const original = context.request const original = context.request
let arrangement, deferredFailure, raceDone = false, unarrangedDone = false let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false
const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
context.request = async function (method, path, body, ...rest) { context.request = async function (method, path, body, ...rest) {
@@ -96,11 +99,18 @@ export async function verifyDualDestination(context, fixture, options) {
proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker)
proof.arrangeRequest = clone(body); await context.checkpoint() proof.arrangeRequest = clone(body); await context.checkpoint()
} }
const response = await original.call(this, method, path, body, ...rest) 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` if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement`
&& response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data)
// 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。
if (response.http === 200 && String(response.body?.code) === '200') { if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') {
try { try {
if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone
&& response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') {
@@ -206,6 +216,49 @@ export async function verifyLoadingReplayRace(context, fixture, options, send, b
race.status = 'VERIFIED'; await context.checkpoint() 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) { async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) {
const proof = context.createdResources.dispatchWorkflowCompetition const proof = context.createdResources.dispatchWorkflowCompetition
const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send),
@@ -239,9 +292,13 @@ export async function verifyCompetitionExtension(context, fixture, options) {
proof.resourceConflict.status = 'VERIFIED' proof.resourceConflict.status = 'VERIFIED'
const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) const verified = await verifyDualDestination(context, fixture, { ...options, competition: true })
assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED')
proof.status = 'VERIFIED_BOUNDED_SCOPE'; await context.checkpoint(); return 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实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */ /** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */
export async function prepareBolAppointments(context, members, scenario, warehouseId) { export async function prepareBolAppointments(context, members, scenario, warehouseId) {
assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario)) assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario))
@@ -427,6 +484,261 @@ export async function verifyReviewExtension(context, fixture, options) {
return verifyDualDestination(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. */ /** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */
export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) { export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) {
assert.equal(prior.success, false) assert.equal(prior.success, false)
@@ -1,18 +1,18 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, assertBolPages } from './dispatch-workflow-extension-acceptance.mjs' 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' import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs'
test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => { test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => {
const plan = await runDispatchExtensions(parseExtensionArguments([]), {}) const plan = await runDispatchExtensions(parseExtensionArguments([]), {})
assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5) assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5)
assert.equal(extensionMatrix.find(row => row.scenario === 'relabel').readiness, 'REQUIRES_FORMAL_RELABEL_FIXTURE') 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', () => { 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', 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'] '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1']
assert.equal(parseExtensionArguments(args).execute, true) assert.equal(parseExtensionArguments(args).execute, true)
for (const [from, to] of [['TEST', 'DEV'], ['EXT1', 'plan'], ['dual-destination', 'relabel'], ['10', ''], ['1', '0']]) 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.map(value => value === from ? to : value)))
assert.throws(() => parseExtensionArguments([...args, '--execute'])) assert.throws(() => parseExtensionArguments([...args, '--execute']))
}) })
@@ -101,3 +101,38 @@ test('BOL PDF evidence enforces empty/same/different appointment grouping and ac
[pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]]) [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]])
assert.throws(() => assertBolPages(bad, fixture, appointments, 'bol-different')) 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')
})
@@ -8,7 +8,7 @@ import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-
import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs'
import { discoverSecondDestination, verifyDualDestination, verifyReviewExtension, verifyCompetitionExtension, prepareBolAppointments, verifyBolExtension, reconcileReviewTerminal, extensionMatrix } from './admin-test-env-modules/dispatch-workflow-extension-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 ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const hash = bytes => createHash('sha256').update(bytes).digest('hex') const hash = bytes => createHash('sha256').update(bytes).digest('hex')
@@ -27,11 +27,11 @@ export function parseExtensionArguments(args) {
seen.add(name); config[field] = args[++index] 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.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', 'review-terminal', 'bol-empty', 'bol-same', 'bol-different'].includes(config.scenario) || config.runId === 'plan' || !config.pdaEnvFile 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')) || !/^[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') throw Error('Ready scenario, explicit warehouse/destination/PDA config and synthetic pallet-spaces=1 required')
if (config.scenario === 'review-terminal' && (!config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) throw Error('Explicit frozen review report 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 (config.scenario !== 'review-terminal' && (config.resumeReport || config.resumeReportSha256) 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))) && (!config.scenario.startsWith('bol-') || !config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256)))
throw Error('Resume options require an explicit frozen owned checkpoint') 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') if (config.execute && config.scenario.startsWith('bol-') && !config.pdfPython) throw Error('Explicit PDF reader runtime required')
@@ -42,16 +42,24 @@ export async function runDispatchExtensions(config, env = process.env) {
limits: 'No deploy, SQL writes, permission edits, deletion, old manual main mutation, blind retries or uncontrolled fault injection.' } 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') if (env.CHONGCHONG_ENV !== 'test') throw Error('Only the explicitly loaded TEST configuration is accepted')
const bol = config.scenario.startsWith('bol-') const bol = config.scenario.startsWith('bol-')
const relabelResume = config.scenario === 'relabel-shared'
if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 }) if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 })
let prior let prior
if (config.scenario === 'review-terminal' || bol && config.resumeReport) { if (config.scenario === 'review-terminal' || relabelResume || bol && config.resumeReport) {
const sourcePath = resolve(ROOT, config.resumeReport) const sourcePath = resolve(ROOT, config.resumeReport)
const prefix = bol ? 'fleet-cross-DISPATCH20261020EXTBOL' : 'fleet-cross-DISPATCH20261020EXTREV' 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') if (!sourcePath.startsWith(join(ROOT, 'reports', prefix))) throw Error('Only this extension-owned frozen report can be reconciled')
const bytes = readFileSync(sourcePath) const bytes = readFileSync(sourcePath)
if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed') if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed')
prior = JSON.parse(bytes) prior = JSON.parse(bytes)
if (bol) { 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' 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?.dispatchWorkflowBol?.status !== 'CHECKING' || prior.createdResources.dispatchWorkflowBol.scenario !== config.scenario
|| prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer || prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer
@@ -72,7 +80,8 @@ export async function runDispatchExtensions(config, env = process.env) {
if (!pda.PDA_USERNAME || !pda.PDA_PASSWORD) throw Error('Missing authorized existing overseas PDA credentials') 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', 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-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-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 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}`) } 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 }) mkdirSync(join(ROOT, 'reports'), { recursive: true })
@@ -95,7 +104,7 @@ export async function runDispatchExtensions(config, env = process.env) {
if (prior) { if (prior) {
const tripProof = structuredClone(prior.createdResources.fleetTrips[0]) const tripProof = structuredClone(prior.createdResources.fleetTrips[0])
context.createdResources.fleetTrips = [tripProof] context.createdResources.fleetTrips = [tripProof]
context.createdResources[bol ? 'explicitBolCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256, context.createdResources[bol ? 'explicitBolCheckpoint' : relabelResume ? 'explicitRelabelCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256,
originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false } originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false }
if (bol) { if (bol) {
context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments) context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments)
@@ -132,6 +141,7 @@ export async function runDispatchExtensions(config, env = process.env) {
}, },
verify: async (...args) => { verify: async (...args) => {
stable() stable()
if (relabelResume) return reconcileRelabelShared(...args, prior)
if (prior && !bol) return reconcileReviewTerminal(...args, prior) if (prior && !bol) return reconcileReviewTerminal(...args, prior)
if (bol) { if (bol) {
const [context, fixture, options] = args const [context, fixture, options] = args
@@ -155,15 +165,25 @@ export async function runDispatchExtensions(config, env = process.env) {
await context.checkpoint(); return result.pages await context.checkpoint(); return result.pages
} }) } })
} }
return (config.scenario === 'reviews' ? verifyReviewExtension : config.scenario === 'competition' ? verifyCompetitionExtension : verifyDualDestination)(...args) 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)
}, },
finish: async (...args) => { stable(); return finishFleetTrip(...args) },
}) })
return { success: result.success, error: result.error, runId: config.runId, requestCount: result.requestCount, summary: result.summary, 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, sessionsClosed: result.sessionsClosed, scenario: config.scenario, extensionStatus: result.createdResources?.dispatchWorkflowExtensions?.status,
reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status, reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status,
competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status, competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status,
bolStatus: result.createdResources?.dispatchWorkflowBol?.status, bolStatus: result.createdResources?.dispatchWorkflowBol?.status,
relabelStatus: result.createdResources?.dispatchWorkflowRelabel?.status,
report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false } report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false }
} }
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
@@ -57,6 +57,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经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', http('lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'排车车辆及司机证据通过File内部令牌入口按平台0、证据类别和排车ID复合核验;POST上传无远端幂等键,禁止自动重试,GET元数据及有界内容只读,不借文件上传推进排车状态'), '排车车辆及司机证据通过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') const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort() .map(client => client.path).sort()
assert.equal(reviewed.length, 29) assert.equal(reviewed.length, 30)
assert.deepEqual(discovered.sort(), reviewed) 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 () => { test('IAM customer dimension Feign is reachable only from the background relay', async () => {
const references = [] const references = []
for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) {