diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 336d17dea..90a27fb40 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +自有业务运营视图的结构在 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、未部署。 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ecb9eaca6..db18e9fdd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6856,6 +6856,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index b821e6b70..6ce789b99 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4882,4 +4882,84 @@ CREATE TABLE ll_fleet_pickup_history ( KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; +-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( + dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', + resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', + vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', + vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED', + vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本', + ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间', + loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要', + loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间', + PRIMARY KEY (dispatch_id), + KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id), + CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT ck_owned_business_resource_mode CHECK ( + (resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0) + OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( + id BIGINT NOT NULL COMMENT '附加运营记录内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', + currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空', + total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用', + actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空', + fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里', + arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间', + photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照', + created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识', + updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id), + CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), + CONSTRAINT ck_owned_business_money CHECK ( + (currency IS NULL AND total_amount IS NULL) + OR (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_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( + id BIGINT NOT NULL COMMENT '附加运营命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', + idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作', + expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空', + request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要', + request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求', + status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT', + result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实', + error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码', + created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key), + CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0), + CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( + id BIGINT NOT NULL COMMENT '附加运营历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识', + reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明', + snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version), + CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; + SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 850060c90..ed10c57fe 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,8 @@ enum BusinessFileCategory { FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -71,6 +73,11 @@ enum BusinessFileCategory { /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + // 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。 + if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); + } if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); diff --git a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml new file mode 100644 index 000000000..f1c9fd9f9 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: 自有业务排车运营照片归属契约 + version: 1.0.0 + description: >- + 自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。 + 复用InternalBusinessFileController,不增加公网File路径。 + businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, + tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 + 仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 + Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 + 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 + File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java new file mode 100644 index 000000000..d608a51f3 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java @@ -0,0 +1,125 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OwnedBusinessPhotoFileTest { + private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO"; + private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO"; + @TempDir Path root; + + @BeforeAll static void columns() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1}); + for (long owner : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class); + for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""}) + assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { + var storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + var category = BusinessFileCategory.require(type); + for (String format : new String[]{"jpeg", "png"}) { + byte[] bytes = image(format); + var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes); + var saved = storage.store(0, "71", upload, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(bytes); + } + for (var upload : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), + new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), + new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())}) + assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class); + verify(oversized, never()).getInputStream(); + } + + /** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */ + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + byte[] bytes = image("png"); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length); + row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(bound(query, "status")).isEqualTo("ACTIVE"); + assertThat(bound(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type")) + && "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null; + }); + when(storage.read("owned-photo")).thenReturn(bytes); + assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes); + clearInvocations(storage); + for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING, + "FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) { + assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + private static Object bound(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(?new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); + // 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。 + // 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。 + ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(), + context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at); return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java new file mode 100644 index 000000000..ff8898e86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 只读原排车装车后事实,仅附加登记和照片可写。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business") +public class OwnedBusinessController { + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessPhotoService photos; + private final OwnedBusinessSupport access; + /** Controller不直调Repository/Mapper,身份只取会话。 */ + public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) { + this.queries=queries;this.records=records;this.photos=photos;this.access=access; + } + /** 列表和完整统计一次返回。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));} + /** list权限包含独立详情。 */ + @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));} + /** 原排车和附加历史在同一快照分页,不逐条GET。 */ + @GetMapping("/{dispatchNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) { + return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size)); + } + /** 金额可以更正/清空,但不推进主单或触发财务。 */ + @PutMapping("/{dispatchNo}/operations") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) { + return ApiResponse.success(records.save(access.actor(user),dispatchNo,request)); + } + /** 上传仅准备File,关联保存另有独立原键。 */ + @PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 绑定失败保留fileId和原请求,不重新上传。 */ + @PutMapping("/{dispatchNo}/photos/{slot}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request)); + } + /** 附件只允许当前或精确历史引用读取。 */ + @GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) { + var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(result.content()); + } + /** 不自动补写或向别的账号披露原命令。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java new file mode 100644 index 000000000..2d439889f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import java.time.LocalDateTime; +import java.util.Set; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** File保存照片,本域另行授权原排车及分类引用。 */ +@Component +public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { + public static final long MAX_SIZE=10L*1024*1024; + private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}"; + private final RestClient client; + private final String token; + /** 沿既有内部File客户端,保持外部访问域封闭。 */ + public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token; + } + /** MIME和字节签名一起校验,File仍执行其权威校验。 */ + public static void validate(Upload upload) { + if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255 + || upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE + || !image(upload.contentType()) || !signature(upload.contentType(),upload.content())) + throw bad("照片须为非空真实JPG/PNG,最大10MiB"); + } + /** 使用原图片字节,未知结果不能换键或自动重传。 */ + @Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) { + validate(upload);var body=new MultipartBodyBuilder(); + body.part("file",new ByteArrayResource(upload.content()){ + /** 保留本轮原文件名,不生成占位证据。 */ + @Override public String getFilename(){return upload.fileName();} + }).contentType(MediaType.parseMediaType(upload.contentType())); + var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token) + .header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class); + return verified(result,dispatchId,slot,null); + } + /** 不能借另一客户、另一分类或另一排车的文件授权。 */ + @Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) { + return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId) + .header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId); + } + /** 防止把错误页或其他内容冒充图片返回。 */ + @Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) { + var info=metadata(dispatchId,slot,fileId); + byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId) + .header("X-Internal-Service-Token",token).retrieve().body(byte[].class); + if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致"); + return bytes; + } + /** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */ + public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) { + if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功"); + var m=response.data(); + if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType()) + || !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId()) + || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType()) + || m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效"); + return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString()); + } + /** 不放开PDF、SVG或任意image类型。 */ + private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);} + /** 使用真实文件头,不转换或伪造证据图片。 */ + private static boolean signature(String type,byte[] bytes) { + if(bytes==null)return false; + if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255; + byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.lengthSELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN #{id}") + List records(@Param("ids") List ids); + /** CAS仅修改运营附加字段,不占用主单recordVersion。 */ + @Update(""" + UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency}, + total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles}, + fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson}, + photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt} + WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected} + """) + int update(@Param("r") Record row,@Param("expected") long expected); + /** 命令键全车唯一,操作者在服务端另行核验。 */ + @Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}") + Command command(@Param("id") long id,@Param("key") String key); + /** 完整意图在文件HTTP之前单独事务提交。 */ + @Insert(""" + INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action, + expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at) + VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion}, + #{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt}) + """) + int insertCommand(Command row); + /** 上传只完成原待定意图,不能把未知结果当新命令重做。 */ + @Update(""" + UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson}, + error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING' + """) + int finishUpload(Command row); + /** 附加写与不可变历史同事务,版本不能重复。 */ + @Insert(""" + INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id, + reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId}, + #{reason},#{snapshotJson},#{occurredAt}) + """) + int insertHistory(History row); + /** 历史按本页版本顺序分页。 */ + @Select(""" + SELECT * FROM ( + SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source, + action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} + UNION ALL + SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source, + action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} + ) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset} + """) + List history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit); + /** 历史总数不取当前页长度。 */ + @Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})") + int historyCount(long id); + /** 历史文件只能从指定真实历史快照读取。 */ + @Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}") + History revision(@Param("id") long id,@Param("version") long version); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java new file mode 100644 index 000000000..62986d7ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */ +@Mapper +public interface OwnedBusinessQueryMapper { + String SOURCE=""" + SELECT d.*,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.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 + """; + String FILTER=""" + d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') + AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL + AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash + AND d.planned_departure_at >= #{q.from} + AND d.planned_departure_at < #{q.to} + AND d.stage=#{q.stage} + AND d.overseas_warehouse_id=#{q.warehouseId} + AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%') + OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i + WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') + OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') + OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%')))) + """; + /** 只返回当前完整条件下的一车一行。 */ + @Select("") + List page(@Param("q") Search query); + /** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */ + @Select("") + List summary(@Param("q") Search query); + /** 先读原主单和可空归属,以明确区别无快照与不存在。 */ + @Select(SOURCE+" WHERE d.dispatch_no=#{number}") + Source find(String number); + /** 页内全部货物一次读取,各客户保持原归属。 */ + @Select("") + List cargo(@Param("ids") List ids); + /** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */ + @Select(""" + + """) + List batches(@Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java new file mode 100644 index 000000000..25d841394 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Set; + +/** 原排车只读投影和附加信息,不公开第二套履约命令。 */ +public final class OwnedBusinessModels { + /** 工具容器不允许实例化。 */ + private OwnedBusinessModels() {} + public static final String LIST="platform:fleet:owned-business:list"; + public static final String EDIT="platform:fleet:owned-business:edit"; + public static final String UPLOAD="platform:fleet:owned-business:upload"; + public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED } + public enum PhotoSlot { + LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END; + /** 图片框分类不表示司机审核或真实派送签收。 */ + public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; } + /** 文件类型与租户归属由本域固定,浏览器不能指定。 */ + public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; } + } + public record Actor(String operatorId,String operatorName,Set permissions) {} + public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size, + String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword, + Stage stage,@Size(max=64) String warehouseId) {} + public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {} + public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {} + public record Operations(@Valid Money money,String actualDistanceMiles, + String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {} + public record SaveOperations(@NotBlank String recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations, + @Size(max=1000) String reason) {} + public record BindPhoto(@NotBlank String recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + public record Gate(boolean allowed,String blockReason) {} + public record Actions(Gate edit,Gate upload) {} + public record Origin(String warehouseId,String warehouseCode,String warehouseName) {} + public record Vehicle(String vehicleId,String plate,String ownership) {} + public record Driver(String name,String phone) {} + public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {} + public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address, + List appointments,String actualArrivedAt) {} + public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType, + String sizeBytes,String uploadedAt,String savedAt) {} + public record PhotoSummary(int loadingCount,int fuelCount) {} + public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt, + String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver, + List destinations,Integer actualPalletCount,Operations operations,String unitPrice, + String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues) {} + public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} + public record ListResult(List records,int page,int size,int total,Summary summary) {} + public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, + String inboundCode,String palletNo,String destinationKey,String packages) {} + public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} + public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason, + Operations operations,List photos) {} + public record HistoryPage(List records,int page,int size,int total) {} + public record Command(String dispatchNo,String action,String idempotencyKey,String state, + String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {} + public record Snapshot(Operations operations,List photos,String operatorName) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java new file mode 100644 index 000000000..826b923a5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 仅供本包持久化,不向浏览器输出内部主键。 */ +public final class OwnedBusinessRows { + /** 持久化容器不允许实例化。 */ + private OwnedBusinessRows() {} + @Data public static class Ownership { + private Long dispatchId,vehicleId,vehicleRecordVersion; + private String resourceMode,vehicleOwnership,loadingCompletionHash; + private LocalDateTime ownershipCapturedAt,loadingCompletedAt; + } + @Data public static class Source extends DispatchAggregate { + private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash; + private Long ownedVehicleId,ownedVehicleRecordVersion; + private LocalDateTime ownedLoadingCompletedAt; + } + @Data public static class Record { + private Long id,dispatchId,recordVersion; + private String currency,arrivalsJson,photosJson,createdBy,updatedBy; + private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km; + private LocalDateTime createdAt,updatedAt; + } + @Data public static class Command { + private Long id,dispatchId,expectedVersion; + private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode; + private LocalDateTime createdAt,updatedAt; + } + @Data public static class History { + private Long id,dispatchId,recordVersion,dispatchRecordVersion; + private String eventSource,action,operatorId,reason,snapshotJson; + private LocalDateTime occurredAt; + } + @Data public static class Batch { + private Long dispatchId; + private String destinationKey,appointmentNo,confirmationNo; + private LocalDateTime startUtc; + private Long allocatedPackages; + } + public record Search(int offset,int limit,String keyword,String warehouseId,String stage, + LocalDateTime from,LocalDateTime to) {} + @Data public static class StageCount { private String stage;private Long quantity; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java new file mode 100644 index 000000000..f69de8cf6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.port; + +import java.time.Instant; + +/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */ +public interface OwnedBusinessOwnershipCapture { + /** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */ + void capture(long dispatchId, Long vehicleId, String resourceMode, + String loadingCompletionHash, Instant loadingCompletedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java new file mode 100644 index 000000000..abb6ed487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.repository; + +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper; +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */ +@Repository +public class OwnedBusinessRepository { + private final OwnedBusinessMapper records; + private final OwnedBusinessQueryMapper queries; + /** 注入唯一SQL提供者,Service不能直接访问Mapper。 */ + public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;} + /** 原归属已捕获后不按当前车辆回算。 */ + public Ownership ownership(long id){return records.ownership(id);} + /** 归属与主装车事务共同提交。 */ + public void capture(Ownership row){records.capture(row);} + /** 服务端整车分页。 */ + public List page(Search query){return queries.page(query);} + /** 与分页相同过滤的完整统计。 */ + public List summary(Search query){return queries.summary(query);} + /** 读取唯一原排车身份及归属证据。 */ + public Source source(String number){return queries.find(number);} + /** 页内货物批量查询。 */ + public List cargo(List ids){return ids.isEmpty()?List.of():queries.cargo(ids);} + /** 页内实际预约分配批量查询。 */ + public List batches(List ids){return ids.isEmpty()?List.of():queries.batches(ids);} + /** GET只读,无记录保留虚拟0版本。 */ + public Record record(long id){return records.record(id);} + /** 页内附加信息批量读取。 */ + public List records(List ids){return ids.isEmpty()?List.of():records.records(ids);} + /** 首个写事务建立行,并按dispatch唯一键串行。 */ + public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());} + /** 只CAS修改附加信息。 */ + public int update(Record row,long expected){return records.update(row,expected);} + /** 命令先按全车请求键定位,再在服务端检查原操作者。 */ + public Command command(long id,String key){return records.command(id,key);} + /** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */ + public void command(Command row){records.insertCommand(row);} + /** 上传结果只完成原待定意图。 */ + public int finishUpload(Command row){return records.finishUpload(row);} + /** 同一附加版本只记录一次不可变历史。 */ + public void history(History row){records.insertHistory(row);} + /** 历史总数只取原dispatch。 */ + public int historyCount(long id){return records.historyCount(id);} + /** 历史采用稳定版本倒序。 */ + public List history(long id,int offset,int size){return records.history(id,offset,size);} + /** 下载历史附件时精确定位原版本。 */ + public History revision(long id,long version){return records.revision(id,version);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java new file mode 100644 index 000000000..7b7174361 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.Objects; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */ +@Service +public class OwnedBusinessCommandService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessSupport codec; + /** 文件意图独立短事务,不持业务行锁跨HTTP。 */ + public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;} + public record Reservation(Command command,boolean created) {} + /** 查找原命令时先核操作者,不披露别人的原载荷。 */ + public Command existing(long dispatchId,String key,Actor actor) { + var row=repository.command(dispatchId,key); + if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号"); + return row; + } + /** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */ + public void same(Command row,String action,Long version,Object request) { + if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version) + || !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同"); + } + /** 完整命令实体只用于本页,不保存浏览器凭据。 */ + public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) { + var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id); + row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version); + row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row; + } + /** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public Reservation reserveUpload(long id,Actor actor,String key,Object request) { + var previous=existing(id,key,actor); + if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);} + var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING"); + try{repository.command(row);return new Reservation(row,true);} + catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);} + } + /** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW) + public void finishUpload(Command command,String status,Object result,String errorCode) { + command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode); + command.setUpdatedAt(codec.utc(Instant.now())); + if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求"); + } + /** 写命令与附加更新、历史在同一外部事务提交。 */ + public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) { + var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java new file mode 100644 index 000000000..5c3757fce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.Objects; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */ +@Service +public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture { + private final OwnedBusinessRepository repository; + private final VehicleRepository vehicles; + /** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */ + public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;} + /** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) { + if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null + || !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整"); + if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法"); + if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致"); + var existing=repository.ownership(dispatchId); + if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;} + var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode); + if("MASTER".equals(resourceMode)) { + var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在")); + if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效"); + if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确"); + row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion()); + } else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);} + row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC)); + row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash); + try{repository.capture(row);}catch(DuplicateKeyException duplicate){ + var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate; + same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt); + if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化"); + } + } + /** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */ + private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) { + if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode) + || !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC))) + throw conflict("原装车车辆归属证据不同"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java new file mode 100644 index 000000000..12cec0302 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */ +@Service +public class OwnedBusinessPhotoService { + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** HTTP远程请求不在附加行写事务内持锁等待。 */ + public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records, + OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; + } + public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {} + public record Download(Photo photo,byte[] content) {} + /** 写前落意图,绑定失败须保留原fileId,不能再上传。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { + codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number); + if(slot==null || multipart==null)throw bad("请选择照片及分类"); + if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); + byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} + var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); + HttpOwnedBusinessFileGateway.validate(payload); + var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(source.getId(),actor,key,identity); + if(!reservation.created()) { + if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传"); + return codec.read(reservation.command().getResultJson(),Photo.class); + } + try { + var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; + } catch(RuntimeException failure) { + // 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。 + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");} + catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);} + throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传"); + } + } + /** 重放先于File复核,保证原成功结果可以精确恢复。 */ + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { + codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId()); + if(fileId<=0)throw bad("文件编号无效"); + var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null) { + commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request)); + if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令"); + return codec.read(previous.getResultJson(),Detail.class); + } + return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId))); + } + /** 只有当前或指定真实历史版本中的引用允许代理读取。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { + codec.require(actor,LIST);var source=queries.requireOwned(number);List bound; + if(revision==null)bound=projection.photos(repository.record(source.getId())); + else { + if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision); + if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); + } + var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing); + return new Download(photo,files.content(source.getId(),slot,fileId)); + } + /** 恢复先校验原操作者,不披露他人载荷。 */ + @Transactional(readOnly=true) + public Command command(Actor actor,String number,String key) { + codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor); + if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); + String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1)); + return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false); + } + /** 上传回执不填写保存时间,避免假称已经关联成功。 */ + private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) { + return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(), + String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java new file mode 100644 index 000000000..31bf7bd8d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import org.springframework.stereotype.Component; +import java.math.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */ +@Component +public class OwnedBusinessProjection { + private final OwnedBusinessSupport codec; + /** 所有数值与日期转换沿统一显式规则。 */ + public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;} + /** 未登记为null,不以默认油耗、金额或到达填充。 */ + public Operations operations(Record record) { + if(record==null)return new Operations(null,null,null,List.of()); + var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString()); + return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()), + codec.list(record.getArrivalsJson(),Arrival.class)); + } + /** 照片已绑定才出现在当前记录,无文件不生成占位图。 */ + public List photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);} + /** 使用原货物确定目的地key,保持与排车及到达登记一致。 */ + public String destinationKey(DispatchItem item) { + String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(), + item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); + if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key; + } + /** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */ + public int palletCount(Source source) { + var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class); + if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失"); + var ids=new HashSet(); + for(var pallet:confirmed.pallets()) { + if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失"); + ids.add(pallet.warehousePalletId()); + } + return ids.size(); + } + /** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */ + public Row row(Source source,Record record,List cargo,List batches,Actor actor) { + var resources=codec.read(source.getResourcesJson(),Resources.class); + if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); + var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); + var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); + var destinations=new LinkedHashMap(); + for(var item:cargo) { + String key=destinationKey(item); + destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(), + item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment( + b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k))); + } + String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString(); + var driver=resources.driver(); + return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), + codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), + new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), + new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"), + new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, + record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), + new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), + new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + } + /** 附加权限独立于原排车写动作。 */ + private Gate gate(Actor actor,String permission) { + boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission); + return new Gate(allowed,allowed?null:"没有该业务操作权限"); + } + /** 数据库十进制保持显式单位,不以空值替换0。 */ + private String decimal(BigDecimal value){return value==null?null:value.toPlainString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java new file mode 100644 index 000000000..2c9c7c0a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */ +@Service +public class OwnedBusinessQueryService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final DispatchAggregateService dispatch; + /** 主单详情沿既有真实提供者,列表使用本域批量投影。 */ + public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,DispatchAggregateService dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;} + /** 四项统计与分页使用同一过滤和快照,GET不写库。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ListResult list(Actor actor,Query query) { + codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失"); + int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64); + Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo()); + if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效"); + int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} + var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to)); + var counts=repository.summary(search);int after=0,delivering=0,delivered=0; + for(var c:counts) { + int n=Math.toIntExact(c.getQuantity()); + switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");} + } + int total=Math.addExact(Math.addExact(after,delivering),delivered); + var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList(); + var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r)); + var cargo=repository.cargo(ids);var batches=repository.batches(ids); + var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()), + cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(), + batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList(); + return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)); + } + /** 对原排车详情做组合,不创建第二份主单或履约状态。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));} + /** 写入已经有独立权限;回执仍由相同真实来源组成。 */ + public Detail detailForWrite(Actor actor,Source source) { + eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids); + var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor); + var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); + return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), + c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), + projection.photos(repository.record(source.getId()))); + } + /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ + public Source requireOwned(String number) { + if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); + var source=repository.source(number);if(source==null)throw missing();eligible(source);return source; + } + /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ + private void eligible(Source source) { + if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务"); + if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing(); + if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) + || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); + var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); + if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()) + || !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) + || !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致"); + } + /** 操作记录只返回真实附加版本,不制造初始示例事件。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) { + codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize); + int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} + return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{ + var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null; + return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(), + h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(), + snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos()); + }).toList(),page,size,repository.historyCount(source.getId())); + } + /** 页码初始1,缺失与非法值不能混同。 */ + private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;} + /** 列表和历史均限制单页读取量。 */ + private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;} + /** 空白可选条件归一化,无限长输入明确拒绝。 */ + private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java new file mode 100644 index 000000000..7648aa944 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */ +@Service +public class OwnedBusinessRecordService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessQueryService queries; + private final OwnedBusinessProjection projection; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessSupport codec; + /** 业务规则经Repository读写,命令与历史一起提交。 */ + public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries, + OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) { + this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec; + } + /** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail save(Actor actor,String number,SaveOperations request) { + codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()); + var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} + if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长"); + var ops=normalize(source,request.operations());var row=lock(source,actor); + previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} + requireVersion(row,version); + row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount())); + row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km())); + row.setArrivalsJson(codec.write(ops.arrivals())); + return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason()); + } + /** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) { + codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number); + Object identity=new PhotoBinding(slot,request); + var old=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} + if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框"); + var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} + requireVersion(row,version); + var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); + photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), + verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); + row.setPhotosJson(codec.write(List.copyOf(photos.values()))); + return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,"保存"+slot.name()+"照片"); + } + /** 照片框也参与摘要,不能换URL框位复用原键。 */ + public record PhotoBinding(PhotoSlot slot,BindPhoto request) {} + /** 先校验所有目的地及数值,再进入附加行写事务。 */ + private Operations normalize(Source source,Operations ops) { + Money money=null; + if(ops.money()!=null) { + if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD"); + String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记"); + money=new Money(ops.money().currency(),amount); + } + if(ops.arrivals()==null)throw bad("目的地到达登记不完整"); + var destinations=new HashSet();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item)); + var seen=new HashSet();var arrivals=new ArrayList(); + for(var arrival:ops.arrivals()) { + if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车"); + var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString())); + } + arrivals.sort(Comparator.comparing(Arrival::destinationKey)); + return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3), + codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals)); + } + /** 首次写建立0版本附加行,仅在成功事务中最终提交。 */ + private Record lock(Source source,Actor actor) { + var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId()); + seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); + var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row; + } + /** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */ + private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");} + /** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */ + private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) { + row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion()); + history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history); + var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result; + } + /** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */ + private Detail result(OwnedBusinessRows.Command command) { + if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复"); + return codec.read(command.getResultJson(),Detail.class); + } + /** null是未登记,不以0代替。 */ + private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java new file mode 100644 index 000000000..048c651df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JavaType; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; + +/** 只处理本页格式、身份和JSON,不读写主流程。 */ +@Component +public class OwnedBusinessSupport { + private final ObjectMapper json; + /** 复用服务统一Jackson,不建立第二套日期或版本协议。 */ + public OwnedBusinessSupport(ObjectMapper json){this.json=json;} + /** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */ + public Actor actor(AuthenticatedUser user) { + if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务"); + String operator=user.userId()==null?user.username():user.userId().toString(); + if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号"); + String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName(); + return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions())); + } + /** 写权限独立校验,直接Service调用也不能绕过。 */ + public void require(Actor actor,String permission) { + if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限"); + } + /** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */ + public T read(String value,Class type) { + if(value==null || value.isBlank())throw conflict("缺少可靠业务事实"); + try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");} + } + /** 集合记录与声明的元素类型共同校验。 */ + public List list(String value,Class type) { + JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type); + try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");} + } + /** 原请求和原结果完整持久化,序列化失败必须中止命令。 */ + public String write(Object value) { + try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);} + } + /** 请求摘要不包含版本漂移后的当前数据。 */ + public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));} + /** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */ + public String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));} + catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);} + } + /** 版本0合法,但缺失、负数和超长版本不得降级为0。 */ + public long version(String value) { + try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);} + catch(Exception error){throw bad("请提交有效的原记录版本");} + } + /** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */ + public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");} + /** 只接受带时区的真实时间,统一存UTC。 */ + public Instant instant(String value) { + try{return value==null?null:OffsetDateTime.parse(value).toInstant();} + catch(Exception error){throw bad("时间须包含明确时区");} + } + /** 关系库时间只表示UTC,不取运行机默认时区。 */ + public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);} + /** 展示原真实时间,不根据签收或预计时间填充。 */ + public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} + /** 规范十进制输入,空值与明确零分别保存。 */ + public String decimal(String value,int integers,int scale) { + if(value==null)return null; + if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制"); + return new BigDecimal(value).setScale(scale).toPlainString(); + } + /** 用户输入错误保持项目业务错误约定。 */ + public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + /** 业务并发或来源事实不符不能返回模拟成功。 */ + public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} + /** 不存在只使用业务提示,不泄露内部主键。 */ + public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");} +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a0ba74737..c672a0de6 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -4553,3 +4553,83 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( KEY idx_pickup_history_source(customer_id,transfer_no,id), KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; + +-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( + dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', + resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', + vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', + vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED', + vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本', + ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间', + loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要', + loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间', + PRIMARY KEY (dispatch_id), + KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id), + CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT ck_owned_business_resource_mode CHECK ( + (resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0) + OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( + id BIGINT NOT NULL COMMENT '附加运营记录内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', + currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空', + total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用', + actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空', + fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里', + arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间', + photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照', + created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识', + updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id), + CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), + CONSTRAINT ck_owned_business_money CHECK ( + (currency IS NULL AND total_amount IS NULL) + OR (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_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( + id BIGINT NOT NULL COMMENT '附加运营命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', + idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作', + expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空', + request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要', + request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求', + status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT', + result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实', + error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码', + created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key), + CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0), + CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( + id BIGINT NOT NULL COMMENT '附加运营历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识', + reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明', + snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version), + CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml new file mode 100644 index 000000000..2b2b9372d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -0,0 +1,975 @@ +openapi: "3.0.3" +info: + title: "原排车自有车辆运营视图" + version: "1.0.0" + description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。" +security: + - + bearerAuth: [] +paths: + "/api/platform/v1/last-leg/fleet/owned-business": + get: + operationId: "listOwnedBusiness" + summary: "自有车辆装车后视图及同筛选四项统计" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/ListResult" + nullable: true + parameters: + - + name: "page" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + default: 1 + - + name: "size" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + maximum: 200 + default: 20 + - + name: "plannedDepartureFrom" + in: "query" + required: false + schema: + type: "string" + format: "date-time" + - + name: "plannedDepartureTo" + in: "query" + required: false + schema: + type: "string" + format: "date-time" + - + name: "keyword" + in: "query" + required: false + schema: + type: "string" + maxLength: 128 + - + name: "stage" + in: "query" + required: false + schema: + "$ref": "#/components/schemas/Stage" + - + name: "warehouseId" + in: "query" + required: false + schema: + type: "string" + maxLength: 64 + x-filter-contract: "plannedDepartureAt [from,to),统计同全部过滤、不带分页;无归属快照不降级按当前主档推断。" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + get: + operationId: "getOwnedBusiness" + summary: "组合原排车详情及附加运营信息" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/history": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + get: + operationId: "historyOwnedBusiness" + summary: "原排车及附加真实流水聚合分页" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/HistoryPage" + nullable: true + parameters: + - + name: "page" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + default: 1 + - + name: "size" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + maximum: 200 + default: 20 + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/operations": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + put: + operationId: "saveOwnedBusinessOperations" + summary: "按附加版本登记、更正或清空运营信息" + description: "权限:platform:fleet:owned-business:edit。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:edit" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + requestBody: + required: true + content: + "application/json": + schema: + "$ref": "#/components/schemas/SaveOperations" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/uploads": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + post: + operationId: "uploadOwnedBusinessPhoto" + summary: "逐框准备照片,尚未保存绑定" + description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:upload" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Photo" + nullable: true + parameters: + - + name: "idempotencyKey" + in: "query" + required: true + schema: + type: "string" + minLength: 8 + maxLength: 128 + requestBody: + required: true + content: + "multipart/form-data": + schema: + type: "object" + required: + - "file" + properties: + file: + type: "string" + format: "binary" + description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + put: + operationId: "bindOwnedBusinessPhoto" + summary: "保存原上传fileId;失败保留原键恢复,不重新上传" + description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:upload" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + requestBody: + required: true + content: + "application/json": + schema: + "$ref": "#/components/schemas/BindPhoto" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/files/{fileId}/content": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + - + name: "fileId" + in: "path" + required: true + schema: + type: "string" + pattern: "^[1-9][0-9]*$" + get: + operationId: "readOwnedBusinessPhoto" + summary: "授权读取当前或原历史绑定图片" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "仅当前或指定历史引用的图片字节,Cache-Control:no-store,X-Content-Type-Options:nosniff" + content: + "image/jpeg": + schema: + type: "string" + format: "binary" + "image/png": + schema: + type: "string" + format: "binary" + parameters: + - + name: "revisionVersion" + in: "query" + required: false + schema: + type: "integer" + format: "int64" + minimum: 1 + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/commands/{idempotencyKey}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "idempotencyKey" + in: "path" + required: true + schema: + type: "string" + minLength: 8 + maxLength: 128 + get: + operationId: "recoverOwnedBusinessCommand" + summary: "恢复本操作者原命令,未知结果不得换键重发" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Command" + nullable: true +components: + securitySchemes: + bearerAuth: + type: "http" + scheme: "bearer" + bearerFormat: "JWT" + schemas: + Stage: + type: "string" + enum: + - "AFTER_LOADING" + - "DELIVERING" + - "DELIVERED" + PhotoSlot: + type: "string" + enum: + - "LOADING_SCENE" + - "LOADING_LIST" + - "PRE_DEPARTURE_CHECK" + - "DRIVER_LICENSE" + - "RECEIPT" + - "FUEL_START" + - "FUEL_END" + Money: + type: "object" + x-java-record: "OwnedBusinessModels.Money" + required: + - "currency" + - "amount" + properties: + currency: + type: "string" + enum: + - "CNY" + - "USD" + amount: + type: "string" + pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$" + Arrival: + type: "object" + x-java-record: "OwnedBusinessModels.Arrival" + required: + - "destinationKey" + - "actualArrivedAt" + properties: + destinationKey: + type: "string" + minLength: 1 + maxLength: 256 + actualArrivedAt: + type: "string" + nullable: true + format: "date-time" + Operations: + type: "object" + x-java-record: "OwnedBusinessModels.Operations" + required: + - "money" + - "actualDistanceMiles" + - "fuelConsumptionLitresPer100Km" + - "arrivals" + properties: + money: + allOf: + - + "$ref": "#/components/schemas/Money" + nullable: true + actualDistanceMiles: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$" + fuelConsumptionLitresPer100Km: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$" + arrivals: + type: "array" + items: + "$ref": "#/components/schemas/Arrival" + SaveOperations: + type: "object" + x-java-record: "OwnedBusinessModels.SaveOperations" + required: + - "recordVersion" + - "idempotencyKey" + - "operations" + properties: + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + operations: + "$ref": "#/components/schemas/Operations" + reason: + type: "string" + nullable: true + maxLength: 1000 + BindPhoto: + type: "object" + x-java-record: "OwnedBusinessModels.BindPhoto" + required: + - "recordVersion" + - "idempotencyKey" + - "fileId" + properties: + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + fileId: + type: "string" + pattern: "^[1-9][0-9]*$" + Gate: + type: "object" + x-java-record: "OwnedBusinessModels.Gate" + required: + - "allowed" + - "blockReason" + properties: + allowed: + type: "boolean" + blockReason: + type: "string" + nullable: true + Actions: + type: "object" + x-java-record: "OwnedBusinessModels.Actions" + required: + - "edit" + - "upload" + properties: + edit: + "$ref": "#/components/schemas/Gate" + upload: + "$ref": "#/components/schemas/Gate" + Origin: + type: "object" + x-java-record: "OwnedBusinessModels.Origin" + required: + - "warehouseId" + - "warehouseCode" + - "warehouseName" + properties: + warehouseId: + type: "string" + warehouseCode: + type: "string" + warehouseName: + type: "string" + Vehicle: + type: "object" + x-java-record: "OwnedBusinessModels.Vehicle" + required: + - "vehicleId" + - "plate" + - "ownership" + properties: + vehicleId: + type: "string" + plate: + type: "string" + ownership: + type: "string" + enum: + - "OWNED" + Driver: + type: "object" + x-java-record: "OwnedBusinessModels.Driver" + required: + - "name" + - "phone" + properties: + name: + type: "string" + nullable: true + phone: + type: "string" + nullable: true + Appointment: + type: "object" + x-java-record: "OwnedBusinessModels.Appointment" + required: + - "appointmentNo" + - "confirmationNo" + - "scheduledAt" + - "allocatedPackages" + properties: + appointmentNo: + type: "string" + confirmationNo: + type: "string" + nullable: true + scheduledAt: + type: "string" + nullable: true + format: "date-time" + allocatedPackages: + type: "string" + pattern: "^[1-9][0-9]*$" + Destination: + type: "object" + x-java-record: "OwnedBusinessModels.Destination" + required: + - "destinationKey" + - "warehouseCode" + - "warehouseName" + - "address" + - "appointments" + - "actualArrivedAt" + properties: + destinationKey: + type: "string" + warehouseCode: + type: "string" + nullable: true + warehouseName: + type: "string" + nullable: true + address: + type: "string" + nullable: true + appointments: + type: "array" + items: + "$ref": "#/components/schemas/Appointment" + actualArrivedAt: + type: "string" + nullable: true + format: "date-time" + Photo: + type: "object" + x-java-record: "OwnedBusinessModels.Photo" + required: + - "slot" + - "category" + - "fileId" + - "fileName" + - "contentType" + - "sizeBytes" + - "uploadedAt" + - "savedAt" + properties: + slot: + "$ref": "#/components/schemas/PhotoSlot" + category: + type: "string" + enum: + - "LOADING" + - "FUEL" + fileId: + type: "string" + fileName: + type: "string" + contentType: + type: "string" + enum: + - "image/jpeg" + - "image/png" + sizeBytes: + type: "string" + pattern: "^[1-9][0-9]*$" + uploadedAt: + type: "string" + savedAt: + type: "string" + nullable: true + format: "date-time" + description: "上传回执savedAt为空;绑定成功才填写savedAt。File类型分别为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,平台0、原dispatch内部ID,最多10MiB。" + PhotoSummary: + type: "object" + x-java-record: "OwnedBusinessModels.PhotoSummary" + required: + - "loadingCount" + - "fuelCount" + properties: + loadingCount: + type: "integer" + minimum: 0 + fuelCount: + type: "integer" + minimum: 0 + Row: + type: "object" + x-java-record: "OwnedBusinessModels.Row" + required: + - "dispatchNo" + - "stage" + - "plannedDepartureAt" + - "loadingCompletedAt" + - "departedAt" + - "deliveryCompletedAt" + - "origin" + - "vehicle" + - "driver" + - "destinations" + - "actualPalletCount" + - "operations" + - "unitPrice" + - "recordVersion" + - "dispatchRecordVersion" + - "photoSummary" + - "actions" + - "sourceIssues" + properties: + dispatchNo: + type: "string" + stage: + "$ref": "#/components/schemas/Stage" + plannedDepartureAt: + type: "string" + format: "date-time" + loadingCompletedAt: + type: "string" + format: "date-time" + departedAt: + type: "string" + nullable: true + format: "date-time" + deliveryCompletedAt: + type: "string" + nullable: true + format: "date-time" + origin: + "$ref": "#/components/schemas/Origin" + vehicle: + "$ref": "#/components/schemas/Vehicle" + driver: + "$ref": "#/components/schemas/Driver" + destinations: + type: "array" + items: + "$ref": "#/components/schemas/Destination" + actualPalletCount: + type: "integer" + minimum: 0 + nullable: true + operations: + "$ref": "#/components/schemas/Operations" + unitPrice: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + dispatchRecordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + photoSummary: + "$ref": "#/components/schemas/PhotoSummary" + actions: + "$ref": "#/components/schemas/Actions" + sourceIssues: + type: "array" + items: + type: "string" + Summary: + type: "object" + x-java-record: "OwnedBusinessModels.Summary" + required: + - "dispatchCount" + - "afterLoadingCount" + - "deliveringCount" + - "deliveredCount" + properties: + dispatchCount: + type: "integer" + minimum: 0 + afterLoadingCount: + type: "integer" + minimum: 0 + deliveringCount: + type: "integer" + minimum: 0 + deliveredCount: + type: "integer" + minimum: 0 + ListResult: + type: "object" + x-java-record: "OwnedBusinessModels.ListResult" + required: + - "records" + - "page" + - "size" + - "total" + - "summary" + properties: + records: + type: "array" + items: + "$ref": "#/components/schemas/Row" + page: + type: "integer" + minimum: 1 + size: + type: "integer" + minimum: 1 + maximum: 200 + total: + type: "integer" + minimum: 0 + summary: + "$ref": "#/components/schemas/Summary" + Cargo: + type: "object" + x-java-record: "OwnedBusinessModels.Cargo" + required: + - "sourceKey" + - "customerId" + - "customerName" + - "orderNo" + - "inboundCode" + - "palletNo" + - "destinationKey" + - "packages" + properties: + sourceKey: + type: "string" + customerId: + type: "string" + customerName: + type: "string" + nullable: true + orderNo: + type: "string" + nullable: true + inboundCode: + type: "string" + nullable: true + palletNo: + type: "string" + destinationKey: + type: "string" + packages: + type: "string" + Detail: + type: "object" + x-java-record: "OwnedBusinessModels.Detail" + required: + - "row" + - "dispatch" + - "cargo" + - "photos" + properties: + row: + "$ref": "#/components/schemas/Row" + dispatch: + "$ref": "./overseas-business-v1.yaml#/components/schemas/DispatchDetail" + cargo: + type: "array" + items: + "$ref": "#/components/schemas/Cargo" + photos: + type: "array" + items: + "$ref": "#/components/schemas/Photo" + History: + type: "object" + x-java-record: "OwnedBusinessModels.History" + required: + - "eventSource" + - "recordVersion" + - "dispatchRecordVersion" + - "action" + - "operatorName" + - "occurredAt" + - "reason" + - "operations" + - "photos" + properties: + eventSource: + type: "string" + enum: + - "DISPATCH" + - "OPERATIONS" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + dispatchRecordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + action: + type: "string" + operatorName: + type: "string" + nullable: true + occurredAt: + type: "string" + format: "date-time" + reason: + type: "string" + nullable: true + operations: + allOf: + - + "$ref": "#/components/schemas/Operations" + nullable: true + photos: + type: "array" + items: + "$ref": "#/components/schemas/Photo" + description: "UNION ALL读取原排车真实流水与附加流水,一次排序分页;DISPATCH事件recordVersion=null、operations=null、photos=[];OPERATIONS事件dispatchRecordVersion=null。" + HistoryPage: + type: "object" + x-java-record: "OwnedBusinessModels.HistoryPage" + required: + - "records" + - "page" + - "size" + - "total" + properties: + records: + type: "array" + items: + "$ref": "#/components/schemas/History" + page: + type: "integer" + minimum: 1 + size: + type: "integer" + minimum: 1 + maximum: 200 + total: + type: "integer" + minimum: 0 + Command: + type: "object" + x-java-record: "OwnedBusinessModels.Command" + required: + - "dispatchNo" + - "action" + - "idempotencyKey" + - "state" + - "recordVersion" + - "replayed" + - "result" + - "errorCode" + - "retryAllowed" + properties: + dispatchNo: + type: "string" + action: + type: "string" + enum: + - "SAVE_OPERATIONS" + - "UPLOAD_PHOTO" + - "BIND_PHOTO" + idempotencyKey: + type: "string" + state: + type: "string" + enum: + - "SUCCEEDED" + - "PENDING" + - "UNKNOWN_RESULT" + - "REJECTED" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + replayed: + type: "boolean" + result: + nullable: true + oneOf: + - + "$ref": "#/components/schemas/Detail" + - + "$ref": "#/components/schemas/Photo" + errorCode: + type: "string" + nullable: true + retryAllowed: + type: "boolean" + enum: + - false diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 356a5c095..d74c6ed17 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -268,12 +268,25 @@ public class DispatchAggregatePersistenceTest { var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1, p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList(); var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对"); + // 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。 + org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing() + .when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class), + anyString(),anyString(),any(java.time.Instant.class)); + assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request))) + .isInstanceOf(IllegalStateException.class).hasMessage("ownership-capture-failed"); + assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); + assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(approved.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero(); var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result()); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + org.mockito.Mockito.verify(f.ownedBusinessOwnership,org.mockito.Mockito.times(2)).capture(anyLong(), + eq(context.resources().vehicle().vehicleId()),eq(context.resources().vehicle().mode().name()), + anyString(),any(java.time.Instant.class)); + org.mockito.Mockito.verifyNoMoreInteractions(f.ownedBusinessOwnership); } /** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */ @@ -444,6 +457,8 @@ public class DispatchAggregatePersistenceTest { /** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */ public static final class Fixture { + final com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership= + mock(com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture.class); public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service; public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session; public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet; @@ -522,6 +537,7 @@ public class DispatchAggregatePersistenceTest { 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); org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java new file mode 100644 index 000000000..b7337f776 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 全车请求键唯一,原操作者、action、版本、载荷缺一不可。 */ +class OwnedBusinessCommandTest { + /** 已有键不能按另一个operator维度重新执行或读取结果。 */ + @Test void anotherActorCannotBorrowOriginalKey() { + var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED"); + when(repo.command(1,"original-key")).thenReturn(row); + var denied=assertThrows(ResponseStatusException.class,()->service.existing(1,"original-key",new Actor("other","其他测试账号",Set.of()))); + assertEquals(403,denied.getStatusCode().value());verify(repo,never()).command(any()); + } + /** 同一请求原版本0合法,改载荷或改版本不得借用原成功。 */ + @Test void fullOriginalIdentityIsRequiredForReplay() { + var service=new OwnedBusinessCommandService(mock(OwnedBusinessRepository.class),OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED"); + assertDoesNotThrow(()->service.same(row,"SAVE_OPERATIONS",0L,"original")); + assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",1L,"original")); + assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",0L,"changed")); + assertThrows(BizException.class,()->service.same(row,"BIND_PHOTO",0L,"original")); + } + /** 未知上传只有原状态,不创建第二个File调用意图。 */ + @Test void unknownUploadReservationDoesNotBecomeNewWork() { + var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"upload-key","UPLOAD_PHOTO",null,"original","UNKNOWN_RESULT"); + when(repo.command(1,"upload-key")).thenReturn(row); + var reservation=service.reserveUpload(1,OwnedBusinessFixtures.actor(),"upload-key","original"); + assertFalse(reservation.created());assertEquals("UNKNOWN_RESULT",reservation.command().getStatus());verify(repo,never()).command(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java new file mode 100644 index 000000000..90b749a63 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; + +/** 本域消费者的文件归属及格式回归,不重复File提供者全量测试。 */ +class OwnedBusinessFileGatewayTest { + /** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */ + @Test void invalidImageSignaturesAndMimeAreRejected() { + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + } + /** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */ + @Test void metadataCannotCrossCustomerDispatchOrCategory() { + var good=metadata(0L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO"); + assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.LOADING_SCENE,9L).fileId()); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(101L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L)); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(0L,"2","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L)); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.FUEL_START,9L)); + } + /** File所有权使用原dispatch而非trip或客户,照片框不能改变类型。 */ + private HttpOwnedBusinessFileGateway.Metadata metadata(Long tenant,String businessId,String type) { + return new HttpOwnedBusinessFileGateway.Metadata(9L,tenant,type,businessId,"actual.png","image/png",20L,LocalDateTime.parse("2026-10-20T07:30:00")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java new file mode 100644 index 000000000..3653e0e87 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 仅本地测试夹具,不操作业务环境或生成运行样本。 */ +final class OwnedBusinessFixtures { + /** 夹具容器不允许实例化。 */ + private OwnedBusinessFixtures() {} + /** Jackson包含实际时间类型,避免把日期当假字符串。 */ + static OwnedBusinessSupport codec(){return new OwnedBusinessSupport(new ObjectMapper().findAndRegisterModules());} + /** 平台列表、编辑、上传权限保持独立。 */ + static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));} + /** 装车完成但未发车,证明不依赖DEPART历史。 */ + 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"); + 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.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt()); + row.setOwnedResourceMode("MASTER");row.setOwnedVehicleOwnership("OWNED");row.setOwnedVehicleId(7L);row.setOwnedVehicleRecordVersion(3L); + row.setResourcesJson(""" + {"vehicle":{"mode":"MASTER","vehicleId":7,"plate":"TEST-OWN-7","carrierName":null,"vehicleStatus":"IN_SERVICE", + "capacity":{"palletSpaces":10,"maxLoadKg":1000,"maxVolumeCbm":20},"evidenceFileIds":[]}, + "driver":{"mode":"MASTER","driverId":8,"name":"测试司机","phone":null,"evidence":null}} + """); + row.setLoadingConfirmationJson(""" + {"manifestHash":"actual-manifest","pallets":[{"warehousePalletId":31,"palletSpaces":3,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}], + "operatorId":"picker","confirmedAt":"2026-10-20T07:30:00Z"} + """);return row; + } + /** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */ + static List cargo() { + 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) { + var i=new DispatchItem();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.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java new file mode 100644 index 000000000..f004097ed --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessOwnershipService; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.time.*; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 归属来自真实装车时车辆,不从GET和当前主档补造历史。 */ +class OwnedBusinessOwnershipTest { + /** 自有车辆无论承运何种货源,都按真实主档归属捕获。 */ + @Test void capturesRealOwnedVehicleAndOriginalLoadTime() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("OWNED",3))); + var service=new OwnedBusinessOwnershipService(repo,vehicles);var at=Instant.parse("2026-10-20T07:30:00Z"); + service.capture(1,7L,"MASTER","load-1",at); + var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture()); + assertEquals("OWNED",saved.getValue().getVehicleOwnership());assertEquals(3L,saved.getValue().getVehicleRecordVersion()); + assertEquals(LocalDateTime.parse("2026-10-20T07:30:00"),saved.getValue().getLoadingCompletedAt()); + verify(vehicles,never()).lockById(anyLong()); + } + /** 临时外配原模式EXTERNAL原样保存,不伪造车辆档案。 */ + @Test void externalHasNoMasterIdOrVersion() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + new OwnedBusinessOwnershipService(repo,vehicles).capture(1,null,"EXTERNAL","load",Instant.parse("2026-10-20T07:30:00Z")); + var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture()); + assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership()); + assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles); + } + /** 重放沿原证据,不读取后来可能变化的车辆归属。 */ + @Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership(); + owned.setDispatchId(1L);owned.setVehicleId(7L);owned.setResourceMode("MASTER");owned.setVehicleOwnership("OWNED"); + owned.setLoadingCompletionHash("load");owned.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00")); + when(repo.ownership(1)).thenReturn(owned); + new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z")); + verifyNoInteractions(vehicles);verify(repo,never()).capture(any()); + assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","different",Instant.parse("2026-10-20T07:30:00Z"))); + } + /** MASTER没有真实车辆时阻断原装车事务,不退回外配。 */ + @Test void rejectsMissingVehicleInsteadOfInventingOwnership() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.empty()); + assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"))); + verify(repo,never()).capture(any()); + } + /** 真实车辆快照由端口返回,测试不依赖车辆存储实现。 */ + private VehicleRepository.VehicleSnapshot vehicle(String ownership,long version) { + return new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US",ownership,"IN_SERVICE",null,version,null,LocalDateTime.parse("2026-10-20T07:30:00"),10,null,null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java new file mode 100644 index 000000000..565a71c45 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -0,0 +1,194 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.common.core.exception.BizException; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +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.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import java.time.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 本机H2/MyBatis真实四表回归,不调用环境或复用外部数据库。 */ +class OwnedBusinessPersistenceTest { + JdbcTemplate jdbc; + OwnedBusinessRepository repository; + OwnedBusinessSupport codec; + OwnedBusinessProjection projection; + OwnedBusinessRecordService service; + OwnedBusinessQueryService queries; + OwnedBusinessCommandService commands; + DataSourceTransactionManager manager; + /** 精确读取根新增4表基线,测试只适配H2方言,不重写业务列或约束。 */ + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:owned-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds); + jdbc.execute(""" + 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), + 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)) + """); + jdbc.execute(""" + CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64), + customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128), + pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32), + destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128), + delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT) + """); + jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)"); + jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))"); + jdbc.execute(""" + CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32), + operator_id VARCHAR(64),reason VARCHAR(1000),snapshot_json LONGTEXT,occurred_at DATETIME(3)) + """); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String suffix:List.of("ownership","record","command","history")) { + var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema); + assertTrue(matcher.find(),"缺少根维护的"+suffix+"表"); + String ddl=matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"); + jdbc.execute(ddl); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(OwnedBusinessMapper.class);config.addMapper(OwnedBusinessQueryMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + repository=new OwnedBusinessRepository(session.getMapper(OwnedBusinessMapper.class),session.getMapper(OwnedBusinessQueryMapper.class)); + codec=OwnedBusinessFixtures.codec();projection=new OwnedBusinessProjection(codec); + commands=new OwnedBusinessCommandService(repository,codec);queries=mock(OwnedBusinessQueryService.class); + var source=OwnedBusinessFixtures.source(); + when(queries.requireOwned("DS-OWN-ONE")).thenReturn(source); + when(queries.detailForWrite(any(),any())).thenAnswer(call->new Detail( + projection.row(source,repository.record(1),repository.cargo(List.of(1L)),repository.batches(List.of(1L)),call.getArgument(0)), + null,List.of(),projection.photos(repository.record(1)))); + service=tx(new OwnedBusinessRecordService(repository,queries,projection,commands,codec)); + jdbc.update(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at, + loading_completed_at,loading_completion_hash,loading_confirmation_json,resources_json,vehicle_plate, + overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name) + VALUES(1,'DS-OWN-ONE',2,'ACTIVE','AFTER_LOADING',8,?,?,?,?,?,'TEST-OWN-7','9','WH-9','测试仓') + """,source.getPlannedDepartureAt(),source.getLoadingCompletedAt(),source.getLoadingCompletionHash(),source.getLoadingConfirmationJson(),source.getResourcesJson()); + for(var item:OwnedBusinessFixtures.cargo())jdbc.update(""" + INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,customer_name,source_key,order_no,inbound_code, + pallet_no,warehouse_pallet_id,destination_type,destination_warehouse_code,destination_warehouse_name,package_count) + VALUES(?,1,?,?,?,?,?,?,31,'FBA','ONT8','ONT8仓',1) + """,item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getOrderNo(),item.getInboundCode(),item.getPalletNo()); + jdbc.update("INSERT INTO ll_dispatch_history VALUES(99,1,8,'LOADING_COMPLETE','picker',NULL,'{}',?)",source.getLoadingCompletedAt()); + var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(1L);ownership.setVehicleId(7L); + ownership.setResourceMode("MASTER");ownership.setVehicleOwnership("OWNED");ownership.setVehicleRecordVersion(3L); + ownership.setOwnershipCapturedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletionHash("actual-load"); + repository.capture(ownership); + } + /** 明确NULL更新真实落库,原主单版本、货物和两个客户完全不改。 */ + @Test void savesThenClearsNullableValuesWithRealMapperCas() { + var first=request("save-money-first",0,new Money("CNY","30"),"12.5","18.6",List.of(new Arrival("FBA:ONT8","2026-10-20T16:00:00+08:00"))); + var saved=service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first); + assertEquals("1",saved.row().recordVersion());assertEquals("30.00",saved.row().unitPrice()); + assertEquals("18.600",repository.record(1).getFuelConsumptionLitresPer100Km().toPlainString()); + assertEquals("2026-10-20T08:00:00Z",codec.list(repository.record(1).getArrivalsJson(),Arrival.class).get(0).actualArrivedAt()); + service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("clear-money-now",1,null,null,null,List.of())); + var cleared=repository.record(1);assertNull(cleared.getCurrency());assertNull(cleared.getTotalAmount()); + assertNull(cleared.getActualDistanceMiles());assertNull(cleared.getFuelConsumptionLitresPer100Km());assertEquals(2L,cleared.getRecordVersion()); + 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)); + } + /** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */ + @Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() { + var first=request("first-operation",0,new Money("USD","0"),null,null,List.of()); + assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion()); + assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion()); + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("stale-operation",0,null,null,null,List.of()))); + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("first-operation",0,new Money("USD","1"),null,null,List.of()))); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class)); + } + /** 非本车目的地和不规范单位输入不能产生附加行或命令。 */ + @Test void rejectsForeignDestinationBeforeAnyWrite() { + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE", + request("foreign-arrival",0,null,null,null,List.of(new Arrival("FBA:OTHER","2026-10-20T08:00:00Z"))))); + 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)); + } + /** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */ + @Test void historyMergesOriginalDispatchWithoutDuplicatingIt() { + 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 page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20); + assertEquals(2,page.total());assertEquals(2,page.records().size()); + var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow(); + assertEquals("8",original.dispatchRecordVersion());assertNull(original.recordVersion());assertNull(original.operations());assertEquals(List.of(),original.photos()); + var added=page.records().stream().filter(r->r.eventSource().equals("OPERATIONS")).findFirst().orElseThrow(); + assertEquals("1",added.recordVersion());assertNull(added.dispatchRecordVersion()); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history",Integer.class)); + } + /** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */ + @Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() { + var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class)); + 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("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount()); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + var empty=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T08:00:00Z","2026-10-20T08:00:01Z",null,null,null)); + assertEquals(1,empty.total()); + var excluded=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,null,"2026-10-20T08:00:00Z",null,null,null)); + assertEquals(0,excluded.total()); + } + /** 原车辆归属捕获与调用方事务同回滚,不留下假的历史所有权。 */ + @Test void ownershipCaptureRollsBackWithParentTransaction() { + jdbc.update("DELETE FROM ll_fleet_owned_business_ownership"); + var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.of(new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US","OWNED","IN_SERVICE",null,3,null,LocalDateTime.now(),10,null,null))); + var capture=tx(new OwnedBusinessOwnershipService(repository,vehicles)); + assertThrows(IllegalStateException.class,()->new TransactionTemplate(manager).execute(status->{ + capture.capture(1,7L,"MASTER","actual-load",Instant.parse("2026-10-20T07:30:00Z"));throw new IllegalStateException("模拟主装车回滚"); + })); + assertNull(repository.ownership(1));verify(vehicles,never()).lockById(anyLong()); + } + /** 两个真实数据库事务争用初始0版本,必须只有一个命令及历史成功。 */ + @Test void concurrentZeroVersionWritesHaveExactlyOneWinner() throws Exception { + var pool=java.util.concurrent.Executors.newFixedThreadPool(2); + var start=new java.util.concurrent.CountDownLatch(1); + try { + var first=pool.submit(()->compete(start,"concurrent-first")); + var second=pool.submit(()->compete(start,"concurrent-second")); + start.countDown(); + boolean a=first.get(10,java.util.concurrent.TimeUnit.SECONDS),b=second.get(10,java.util.concurrent.TimeUnit.SECONDS); + assertNotEquals(a,b);assertEquals(1L,repository.record(1).getRecordVersion()); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class)); + assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class)); + } finally {pool.shutdownNow();} + } + /** 只把真实版本冲突判为竞争失败,SQL和基础设施异常必须让回归失败。 */ + private boolean compete(java.util.concurrent.CountDownLatch start,String key) throws Exception { + if(!start.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new IllegalStateException("并发测试起点超时"); + try{service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request(key,0,new Money("CNY","1"),null,null,List.of()));return true;} + catch(BizException error){if(error.getResultCode()!=com.baiyue.common.core.api.ResultCode.CONFLICT)throw error;return false;} + } + /** 基于实际数据库事务代理调用,验证的不只是实现分支。 */ + @SuppressWarnings("unchecked") + private T tx(T value) { + var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy(); + } + /** 每个请求保留原附加版本和明确量纲。 */ + private SaveOperations request(String key,long version,Money money,String distance,String fuel,List arrivals) { + return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java new file mode 100644 index 000000000..0e91f16fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java @@ -0,0 +1,88 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 上传未知结果和绑定失败恢复回归,不发任何环境HTTP。 */ +class OwnedBusinessPhotoTest { + OwnedBusinessPhotoService service; + OwnedBusinessFileGateway files; + OwnedBusinessRecordService records; + OwnedBusinessQueryService queries; + OwnedBusinessRepository repository; + Map ledger; + /** 用独立命令集合记录原键,文件服务全部为明确的本机mock。 */ + @BeforeEach void setup() { + var codec=OwnedBusinessFixtures.codec();repository=mock(OwnedBusinessRepository.class);queries=mock(OwnedBusinessQueryService.class); + records=mock(OwnedBusinessRecordService.class);files=mock(OwnedBusinessFileGateway.class);ledger=new HashMap<>(); + when(queries.requireOwned("DS-OWN-ONE")).thenReturn(OwnedBusinessFixtures.source()); + when(repository.command(eq(1L),anyString())).thenAnswer(call->ledger.get(call.getArgument(1))); + doAnswer(call->{OwnedBusinessRows.Command row=call.getArgument(0);ledger.put(row.getIdempotencyKey(),row);return null;}).when(repository).command(any(OwnedBusinessRows.Command.class)); + when(repository.finishUpload(any())).thenReturn(1); + service=new OwnedBusinessPhotoService(queries,records,repository,new OwnedBusinessCommandService(repository,codec), + new OwnedBusinessProjection(codec),codec,files); + } + /** 相同原上传请求只执行一次File,回执未绑定不能声称已经保存。 */ + @Test void successfulUploadReplaysOriginalFileWithoutUploadingAgain() { + when(files.upload(eq(1L),eq(PhotoSlot.LOADING_SCENE),eq("operator-1"),any())).thenReturn(evidence()); + var first=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png()); + var replay=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png()); + assertEquals(first,replay);assertEquals("9",first.fileId());assertNull(first.savedAt()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any());verifyNoInteractions(records); + } + /** File响应未知必须保留原键并停写,原请求重试不能再次上传。 */ + @Test void unknownUploadStopsInsteadOfRepeatingFileWrite() { + when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("模拟连接中断")); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png())); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png())); + var recovered=service.command(OwnedBusinessFixtures.actor(),"DS-OWN-ONE","upload-unknown"); + assertEquals("UNKNOWN_RESULT",recovered.state());assertFalse(recovered.retryAllowed());assertNull(recovered.result()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 相同键不能从装车框切换到油耗框,也不能由另一操作者复用。 */ + @Test void uploadIdentityIncludesActorSlotAndOriginalBytes() { + when(files.upload(anyLong(),any(),anyString(),any())).thenReturn(evidence()); + service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-identity",png()); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-identity",png())); + var other=new Actor("other","其他测试账号",Set.of(LIST,UPLOAD)); + assertThrows(ResponseStatusException.class,()->service.command(other,"DS-OWN-ONE","upload-identity")); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 绑定失败后仍消费原fileId/原绑定键,不触发新的上传。 */ + @Test void failedBindingRetainsOriginalFileForExplicitSameRequestRetry() { + when(files.metadata(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(evidence()); + when(records.bind(any(),eq("DS-OWN-ONE"),eq(PhotoSlot.LOADING_SCENE),any(),any())) + .thenThrow(new BizException(ResultCode.CONFLICT,"模拟附加版本冲突")).thenReturn(new Detail(null,null,List.of(),List.of())); + var request=new BindPhoto("0","bind-original","9"); + assertThrows(BizException.class,()->service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request)); + assertNotNull(service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request)); + verify(files,never()).upload(anyLong(),any(),anyString(),any()); + verify(files,times(2)).metadata(1,PhotoSlot.LOADING_SCENE,9); + } + /** 知道文件ID也不能越过当前/历史引用探测内容。 */ + @Test void unboundFileIdIsDeniedBeforeCallingFile() { + assertThrows(BizException.class,()->service.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.RECEIPT,9,null)); + verifyNoInteractions(files); + } + /** 只读用户不能调用上传,File意图也不能落库。 */ + @Test void uploadPermissionIsIndependentFromList() { + assertThrows(ResponseStatusException.class,()->service.upload(new Actor("read-only","只读测试账号",Set.of(LIST)),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-denied",png())); + verifyNoInteractions(files);assertTrue(ledger.isEmpty()); + } + /** 本机原始PNG签名用于消费者格式检查,不声称外部真实照片。 */ + private MockMultipartFile png(){return new MockMultipartFile("file","TEST.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10,0});} + /** 元数据归属在独立gateway测试核验,这里只验证命令生命周期。 */ + private OwnedBusinessFileGateway.Evidence evidence(){return new OwnedBusinessFileGateway.Evidence(9,"TEST.png","image/png",9,"2026-10-20T07:30:00");} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java new file mode 100644 index 000000000..ca91c14c5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +/** 金额按物理托盘计算;原装车和来源客户事实不重新归并。 */ +class OwnedBusinessProjectionTest { + /** 同一实托两客户仍是一托,不按3个标准托位或2个来源除价。 */ + @Test void sharedPhysicalPalletIsCountedOnceAndUnregisteredMoneyStaysNull() { + var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var source=OwnedBusinessFixtures.source(); + var row=p.row(source,null,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals(1,row.actualPalletCount());assertEquals("0",row.recordVersion());assertEquals("8",row.dispatchRecordVersion()); + assertNull(row.operations().money());assertNull(row.unitPrice());assertNull(row.operations().fuelConsumptionLitresPer100Km()); + assertEquals(1,row.destinations().size());assertNull(row.departedAt());assertTrue(row.actions().edit().allowed()); + assertEquals(8L,source.getRecordVersion()); + } + /** 明确0与未登记不同,单价只由服务端权威金额和实托计算。 */ + @Test void explicitZeroAndMoneyRoundingRemainAuthoritative() { + var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var record=record(); + record.setCurrency("CNY");record.setTotalAmount(BigDecimal.ZERO); + var zero=p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals("0.00",zero.operations().money().amount());assertEquals("0.00",zero.unitPrice()); + record.setTotalAmount(new BigDecimal("10.01")); + assertEquals("10.01",p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()).unitPrice()); + } + /** 无实托不除零,不能用来源数或容量代替。 */ + @Test void emptyPhysicalManifestNeverDividesByZero() { + var source=OwnedBusinessFixtures.source();source.setLoadingConfirmationJson("{\"manifestHash\":\"m\",\"pallets\":[],\"operatorId\":\"p\",\"confirmedAt\":\"2026-10-20T07:30:00Z\"}"); + var record=record();record.setCurrency("USD");record.setTotalAmount(BigDecimal.ONE); + var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals(0,row.actualPalletCount());assertNull(row.unitPrice());assertFalse(row.sourceIssues().isEmpty()); + } + /** null与0在每百公里油耗中同样保持区分,不沿用原型18.6。 */ + @Test void explicitFuelRateIsNotDerivedAsTotalFuel() { + var record=record();record.setActualDistanceMiles(BigDecimal.ZERO);record.setFuelConsumptionLitresPer100Km(new BigDecimal("18.600")); + var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record); + assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km()); + } + /** 虚拟记录和空照片保持真实空态。 */ + private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java new file mode 100644 index 000000000..6e0d69a11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */ +class OwnedBusinessQueryTest { + /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ + @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); + var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L); + 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.records(any())).thenReturn(List.of());when(repo.cargo(any())).thenReturn(OwnedBusinessFixtures.cargo());when(repo.batches(any())).thenReturn(List.of()); + var result=service.list(OwnedBusinessFixtures.actor(),new Query(1,1,"2026-10-20T00:00:00+08:00","2026-10-21T00:00:00+08:00","ONT8",null,"9")); + assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(3,result.summary().dispatchCount());assertEquals(2,result.summary().afterLoadingCount()); + var search=ArgumentCaptor.forClass(Search.class);verify(repo).summary(search.capture());verify(repo).page(search.getValue()); + assertEquals(LocalDateTime.parse("2026-10-19T16:00:00"),search.getValue().from()); + assertEquals(LocalDateTime.parse("2026-10-20T16:00:00"),search.getValue().to()); + verify(repo,never()).lock(any());verify(repo,never()).capture(any());verify(repo,never()).update(any(),anyLong()); + } + /** 无归属快照不可用当前主档补造,自有页失败不修改原排车。 */ + @Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.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); + assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE")); + verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion()); + } + /** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */ + @Test void stageAndFrozenOwnershipFactsAreBothRequired() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); + 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("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + } + /** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */ + @Test void readOnlyActorHasOnlyReadGates() { + var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()) + .row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor); + assertFalse(row.actions().edit().allowed());assertFalse(row.actions().upload().allowed()); + } + /** 客户Token即使携带同名权限也不成为平台运营身份。 */ + @Test void customerTokenCannotClaimPlatformThroughPermissions() { + var codec=OwnedBusinessFixtures.codec();var user=new AuthenticatedUser(11L,101L,101L,1L,"客户账号","MERCHANT","WEB",Set.of(LIST,EDIT,UPLOAD)); + assertThrows(ResponseStatusException.class,()->codec.actor(user)); + var platform=new AuthenticatedUser(12L,0L,0L,2L,"平台账号","PLATFORM","WEB",Set.of(LIST)); + assertEquals("平台账号",codec.actor(platform).operatorName());assertEquals("12",codec.actor(platform).operatorId()); + } + /** 缺版本和非规范值不能默认为合法0,超长Long也不能截断。 */ + @Test void versionsAndFuelRateNumbersAreStrict() { + var codec=OwnedBusinessFixtures.codec();assertEquals(0,codec.version("0"));assertEquals("0.000",codec.decimal("0",9,3)); + for(String version:List.of("","-1","00","90071992547409919999"))assertThrows(BizException.class,()->codec.version(version)); + for(String value:List.of("-1","18.6001","NaN","18e1","01"))assertThrows(BizException.class,()->codec.decimal(value,9,3)); + assertThrows(BizException.class,()->codec.instant("2026-10-20T08:00:00")); + } +} diff --git a/scripts/README.md b/scripts/README.md index d149ea7a5..86c71650e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -267,6 +267,16 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc ## 仓库级入口清单 +排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。 + +`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 + +`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。 + +`--scenario bol-empty|bol-same|bol-different --pdf-python <已安装pypdf的Python绝对路径>` 分别创建同目的仓无预约号、同号、不同号的两客户新样本。预约场景通过原有来源预约入口登记明确标记TEST的确认资料及1箱真实份额,再按返回的batchKey/version/来源归属关联原生排车;不借旧预约,不新增日历功能。实际BOL PDF、独立只读二进制请求账本和提取文字保存在该批 `evidence/`;核对跨客户合并/分组、各托只打印一次、总计2箱、打印前后完整库存不变,再继续正常履约结案。JSON请求计数不含独立二进制GET,二者分别留证;不把文字检查作为视觉验收。 + +BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本轮一箱一托来源映射守恒,不声称PDF完整展示托盘码。若本轮在该显示断言处停止且尚未开始履约,可显式传原 `--resume-report` 和 `--resume-report-sha256` 到同一BOL场景、新runId;仅接受原READY夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。 + 下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。 | 分类 | 入口 | 用途与副作用 | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index b8d3b3505..e36917e77 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,11 @@ # Admin 测试环境 HTTP 模块 +`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 + +其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 + +其BOL扩展通过可选`shipmentReferences`和`prepareBatchAllocations`为本轮实收来源提供显式TEST货件参考及正式预约份额;分配准备后重新读取候选当前版本,默认夹具不创建预约。`bol-empty/same/different` 核验真实PDF页数、约号、逐托唯一出现及箱数守恒,打印前后原始库存和主单快照完全不变;实际二进制请求另存只读账本。 + `procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/.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。 diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs new file mode 100644 index 000000000..ed651af09 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs @@ -0,0 +1,490 @@ +import assert from 'node:assert/strict' +import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs' + +const ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const clone = value => structuredClone(value) +const id = value => String(value ?? '') +export const extensionMatrix = Object.freeze([ + { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' }, + { scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' }, + { scenario: 'relabel', scope: 'Confirmed relabel cancellation/rearrangement and multiple applications sharing one physical pallet', readiness: 'REQUIRES_FORMAL_RELABEL_FIXTURE' }, + { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, + { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' }, +]) +export function assertDualDestinationFixture(fixture) { + assert.equal(fixture.members?.length, 2) + assert.equal(new Set(fixture.members.map(row => id(row.customerId))).size, 2) + assert.equal(new Set(fixture.members.map(row => row.destination?.key)).size, 2) + assert(fixture.members.every(row => row.destination?.key && row.receiptVerified === true)) + assert(fixture.tripProof?.stops?.length >= 3) + for (const member of fixture.members) + assert(fixture.tripProof.stops.some(stop => stop.destinationKey === member.destination.key)) +} +export function feeCents(value) { + assert(['string', 'number'].includes(typeof value) && /^(0|[1-9]\d*)(\.\d{1,2})?$/.test(String(value)), 'Non-canonical fee amount') + const [whole, fraction = ''] = String(value).split('.') + const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')) + assert(Number.isSafeInteger(cents)); return cents +} +export function applyIndependentFees(body, groups, members, marker) { + assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2) + assert.equal(new Set(groups.map(row => row.groupKey)).size, 2) + assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2) + const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775])) + const expected = {} + for (const group of body.groups) { + const matches = groups.filter(row => row.groupKey === group.groupKey) + assert.equal(matches.length, 1) + const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId) + assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required') + assert(!expected[customerId], 'Duplicated customer fee group') + for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` } + expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) })) + } + return expected +} + +/** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */ +export async function discoverSecondDestination(context, primaryId) { + const get = async path => { + const response = await context.request('GET', path) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return response.body.data + } + const primary = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(primaryId)}?tenantId=0`) + const country = primary.addresses?.find(row => row.addressLine1)?.countryCode + assert(country && primary.warehouse?.warehouseType === 'FBA' && primary.warehouse.status === 'ACTIVE') + let total, scanned = 0 + const seen = new Set() + for (let page = 1; page <= 100; page++) { + const data = await get(`/api/platform/v1/warehouse-admin?${new URLSearchParams({ tenantId: '0', page: String(page), size: '200', warehouseType: 'FBA', status: 'ACTIVE', countryCode: country })}`) + assert(Array.isArray(data.records)); assert.equal(Number(data.page), page); assert.equal(Number(data.size), 200) + assert(Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0) + total ??= Number(data.total); assert.equal(Number(data.total), total) + assert(data.records.length > 0 || scanned === total) + for (const row of data.records) { + assert(row.id && !seen.has(id(row.id))); seen.add(id(row.id)); scanned++ + if (id(row.id) === id(primaryId)) continue + const candidate = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(row.id)}?tenantId=0`) + if (candidate.warehouse?.warehouseType !== 'FBA' || candidate.warehouse.status !== 'ACTIVE' + || id(candidate.warehouse.tenantId) !== '0' || candidate.warehouse.warehouseCode === primary.warehouse.warehouseCode) continue + if (candidate.addresses?.some(address => address.addressLine1 && address.countryCode === country)) return id(row.id) + } + if (scanned === total) break + } + throw Error('No second distinct public active FBA destination with a real same-country address; no fixture created') +} + +/** Reuse the formal full-chain verifier; only explicitly supplied synthetic fee amounts differ by customer. */ +export async function verifyDualDestination(context, fixture, options) { + if (!options.bol) assertDualDestinationFixture(fixture) + else { + assert.equal(fixture.members.length, 2); assert.equal(new Set(fixture.members.map(row => row.customerId)).size, 2) + assert.equal(new Set(fixture.members.map(row => row.destination.key)).size, 1) + assert(fixture.members.every(row => row.receiptVerified === true)) + } + const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker, + dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, + hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } + const original = context.request + let arrangement, deferredFailure, raceDone = false, unarrangedDone = false + const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + context.request = async function (method, path, body, ...rest) { + if (deferredFailure) throw deferredFailure + if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) { + proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) + proof.arrangeRequest = clone(body); await context.checkpoint() + } + const response = await original.call(this, method, path, body, ...rest) + if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement` + && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) + // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 + if (response.http === 200 && String(response.body?.code) === '200') { + try { + if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone + && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { + raceDone = true + await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data) + } + if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone + && response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) { + unarrangedDone = true + await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data) + } + if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) { + const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true } + for (let attempt = 1; attempt <= 60; attempt++) { + const current = await original.call(this, 'GET', mainPath) + assert.equal(current.http, 200); assert.equal(String(current.body?.code), '200') + assert.equal(current.body.data?.dispatchNo, fixture.dispatchNo) + fence.attempts.push({ attempt, stage: current.body.data.stage, recordVersion: current.body.data.recordVersion, + requestId: current.requestId, traceId: current.traceId }) + if (current.body.data.stage === 'DELIVERED') { fence.status = 'DELIVERED'; break } + assert.equal(current.body.data.stage, 'DELIVERING') + await context.checkpoint(); await new Promise(resolve => setTimeout(resolve, 500)) + } + assert.equal(fence.status, 'DELIVERED', 'Automatic completion did not settle within 30 seconds') + await context.checkpoint() + } + } catch (error) { deferredFailure = error } + } + return response + } + let verified + try { verified = await verifyFleetCrossCustomer(context, fixture, options) } + finally { context.request = original } + assert.equal(verified.status, 'VERIFIED') + const final = verified.snapshots.FINAL + assert.equal(new Set(final.dispatch.documents.deliveries.map(row => row.deliveryNo)).size, 2) + assert.equal(new Set(final.dispatch.documents.outbounds.map(row => row.outboundNo)).size, 2) + const feeIds = new Set() + for (const [index, member] of fixture.members.entries()) { + const delivery = final.members[index].delivery + assert.equal(id(delivery.order.customerId), member.customerId) + assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) + assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1) + const expected = proof.expectedFees[member.customerId] + assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length) + for (const fee of delivery.fees) { + assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id)) + assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE') + } + const other = fixture.members[1 - index] + const path = `${ROOT}/delivery-orders/${encodeURIComponent(delivery.order.deliveryNo)}?customerId=${encodeURIComponent(other.customerId)}` + const response = await context.request('GET', path) + const denied = [200, 403, 404].includes(response.http) && ['A0403', 'A0404'].includes(String(response.body?.code)) + context.assert(MODULE, 'Foreign customer cannot read this delivery or its independent fees', response, denied, '', OPTIONS) + assert(denied) + proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo, + http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) + } + assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId]) + proof.status = 'VERIFIED'; await context.checkpoint(); return verified +} + +const receipt = response => ({ http: response.http, code: response.body?.code, message: response.body?.message, + requestId: response.requestId, traceId: response.traceId }) +async function competitionSnapshot(context, fixture, { readInventory }, send) { + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const state = {} + for (const [name, target] of [['main', path], ['items', `${path}/items`], ['loading', `${path}/loading`], + ['trip', `${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`]]) { + const response = await send('GET', target) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); state[name] = response.body.data + } + state.stock = [] + for (const member of fixture.members) state.stock.push(await readInventory({ marker: context.marker, + customerId: member.customerId, warehouseId: fixture.warehouseId })) + return state +} + +/** 仅两个请求同时发送;使用已成功装车的原键,明确区别首次执行竞争。 */ +export async function verifyLoadingReplayRace(context, fixture, options, send, body, acknowledged) { + const proof = context.createdResources.dispatchWorkflowCompetition + assert(proof && proof.status === 'CHECKING') + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` + const pda = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` + const race = proof.loadingReplayRace = { status: 'INTENT', scope: 'ACKNOWLEDGED_COMMAND_TWO_ACTOR_REPLAY', + firstExecutionRace: 'NOT_TESTED', body: clone(body), before: await competitionSnapshot(context, fixture, options, send) } + await context.checkpoint() + const results = await Promise.allSettled([send('POST', pda, clone(body), fixture.pdaToken), send('POST', path, clone(body))]) + race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' }) + race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT' + await context.checkpoint() + for (const result of results) if (result.status === 'rejected') throw result.reason + const [pdaResult, adminResult] = results.map(result => result.value) + assert.equal(String(pdaResult.body?.code), '200'); assert.equal(pdaResult.body?.data?.replayed, true) + assert.deepEqual(pdaResult.body.data.result, acknowledged.result) + assert.equal(id(pdaResult.body.data.recordVersion), id(acknowledged.recordVersion)) + assert.equal(String(adminResult.body?.code), 'A0403'); assert.match(adminResult.body?.message || '', /操作员/) + race.after = await competitionSnapshot(context, fixture, options, send) + assert.deepEqual(race.after, race.before) + context.assert(MODULE, 'Concurrent Admin/PDA original-key replays preserve the one loading result and complete inventories', + pdaResult, true, '', OPTIONS) + race.status = 'VERIFIED'; await context.checkpoint() +} + +async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) { + const proof = context.createdResources.dispatchWorkflowCompetition + const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), + body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } } + assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0) + await context.checkpoint() + const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken) + state.response = receipt(response); await context.checkpoint() + assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/) + state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before) + context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS) + state.status = 'VERIFIED'; await context.checkpoint() +} + +export async function verifyCompetitionExtension(context, fixture, options) { + const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo, + status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' } + const before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + const body = { idempotencyKey: `${context.marker}-EXT-OCCUPIED-RESOURCE`, warehouseId: fixture.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: fixture.tripProof.vehicleId }, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, + remark: `${context.marker} TEST已占用资源不能分配另一空车`, cargo: [], batchAllocations: [] } + proof.resourceConflict = { status: 'INTENT', body: clone(body), before }; await context.checkpoint() + const response = await context.request('POST', `${ROOT}/dispatches`, body) + proof.resourceConflict.response = receipt(response); await context.checkpoint() + assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /车辆.*占用|司机.*占用/) + proof.resourceConflict.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + assert.deepEqual(proof.resourceConflict.after, before) + context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS) + proof.resourceConflict.status = 'VERIFIED' + const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) + assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') + proof.status = 'VERIFIED_BOUNDED_SCOPE'; await context.checkpoint(); return verified +} + +/** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */ +export async function prepareBolAppointments(context, members, scenario, warehouseId) { + assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario)) + const proof = context.createdResources.dispatchWorkflowBolAppointments = { scenario, status: 'PREPARING', appointments: [], allocations: [] } + if (scenario === 'bol-empty') { proof.status = 'NO_APPOINTMENT'; return [] } + const groups = scenario === 'bol-same' ? [members] : members.map(member => [member]) + for (const [index, group] of groups.entries()) { + for (const member of group) { + const response = await context.request('GET', `${ROOT}/warehouse-transfers/${encodeURIComponent(member.incomingNo)}?customerId=${encodeURIComponent(member.customerId)}`) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200') + const transfer = response.body.data + assert.equal(transfer.transferNo, member.incomingNo); assert.equal(transfer.inboundCode, member.incomingNo) + assert.equal(id(transfer.inboundOrderId), id(member.receipt.balance.inboundOrderId)) + assert.equal(id(transfer.operatingWarehouseId), warehouseId) + assert(transfer.lastlegOrderId && member.selection.planPalletId && member.receiptVerified) + member.appointmentSourceKey = `ORDER:${transfer.lastlegOrderId}` + } + const refs = group.map(member => { + return { customerId: member.customerId, sourceKey: member.appointmentSourceKey, destinationKey: member.destination.key } + }) + const confirmationNo = `TEST-ISA-${context.marker.slice(-12)}-${index + 1}` + const body = { sources: refs, originWarehouseId: warehouseId, fbaWarehouseCode: group[0].destination.warehouseCode, + details: { appointmentStatus: 'SUCCESS', confirmationNo, appointmentAt: new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 19), + appointmentEndAt: new Date(Date.now() + 50 * 3600000).toISOString().slice(0, 19), appointmentRemark: `${context.marker} TEST合成预约确认资料` }, + idempotencyKey: `${context.marker}-BOL-AP-${index + 1}` } + const record = { status: 'INTENT', body: clone(body) }; proof.appointments.push(record); await context.checkpoint() + const response = await context.request('POST', `${ROOT}/appointment-planning/appointments`, body) + record.response = receipt(response); record.status = 'RESPONDED'; await context.checkpoint() + const data = context.apiSuccess(MODULE, 'Create own formal FBA appointment for exact received quantities', response, '', OPTIONS) + assert.equal(String(response.body?.code), '200'); assert(data?.batchKey && data.appointmentNo) + assert.equal(data.confirmationNo, confirmationNo); assert.equal(data.appointmentStatus, 'SUCCESS') + assert.equal(data.items.length, group.length); assert(Number.isSafeInteger(data.version) && data.version >= 0) + record.data = clone(data) + for (const member of group) { + const matches = data.items.filter(item => id(item.customerId) === member.customerId && item.sourceKey === member.appointmentSourceKey) + assert.equal(matches.length, 1); assert.equal(Number(matches[0].packageCount), 1) + assert.equal(matches[0].destinationKey, member.destination.key) + proof.allocations.push({ planPalletId: member.selection.planPalletId, batchKey: data.batchKey, customerId: member.customerId, + sourceKey: matches[0].sourceKey, destinationKey: matches[0].destinationKey, packageCount: 1, recordVersion: data.version }) + } + record.status = 'VERIFIED'; await context.checkpoint() + } + proof.status = 'VERIFIED'; return clone(proof.allocations) +} + +export function assertBolPages(pages, fixture, appointments, scenario) { + const expected = scenario === 'bol-different' ? 2 : 1 + assert.equal(pages.length, expected) + const counts = [], isas = [], flattened = pages.map(page => page.replace(/\s/g, '')) + for (const page of pages) { + assert.match(page, /BILL OF LADING/) + const total = page.match(/Total\s+CTNS:\s*(\d+)/) + assert(total, 'Printed total box count missing'); counts.push(Number(total[1])) + const isaLine = page.split(/\r?\n/).find(line => line.includes('ISA #:')) + assert(isaLine !== undefined); isas.push(isaLine.split('ISA #:')[1].trim()) + } + assert.equal(counts.reduce((sum, count) => sum + count, 0), 2) + // 现行窄托盘列会省略长托盘码;用完整入仓码核对原实收来源,每个来源在本夹具中严格对应一箱一实托。 + for (const member of fixture.members) { + assert(member.incomingNo && member.palletNo) + assert.equal(flattened.reduce((sum, page) => sum + page.split(member.incomingNo).length - 1, 0), 1) + } + const expectedIsas = appointments.appointments.map(row => row.data.confirmationNo) + assert.deepEqual([...isas].sort(), scenario === 'bol-empty' ? [''] : [...expectedIsas].sort()) + if (scenario !== 'bol-different') assert.deepEqual(counts, [2]) + else assert.deepEqual(counts, [1, 1]) + return { pageCount: pages.length, totalPackages: 2, packagesPerBol: counts, confirmationNos: isas, + eachSourcePrintedOnce: true, physicalPalletMapping: 'ONE_BOX_ONE_PALLET_PER_SOURCE', palletColumn: 'LONG_CODES_TRUNCATED_BY_EXISTING_TEMPLATE' } +} + +export async function verifyBolExtension(context, fixture, options) { + assert(typeof options.inspectBol === 'function') + const proof = context.createdResources.dispatchWorkflowBol = { scenario: options.bol, status: 'CHECKING', + physicalMeasurementVerified: false, hardwareVerified: false } + proof.before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + const pages = await options.inspectBol(context, fixture) + proof.printed = assertBolPages(pages, fixture, context.createdResources.dispatchWorkflowBolAppointments, options.bol) + proof.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + assert.deepEqual(proof.after, proof.before) + context.assert(MODULE, 'Actual BOL PDF preserves appointment shares, cross-customer grouping and complete inventory facts', + { http: 200 }, true, '', OPTIONS) + proof.status = 'VERIFIED'; await context.checkpoint() + return verifyDualDestination(context, fixture, options) +} + +/** Exercise edits only on this fresh zero-picked main, then leave a new fully approved round for the formal chain. */ +export async function exerciseReviewLifecycle(context, fixture, { readInventory }) { + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const proof = context.createdResources.dispatchWorkflowReviews = { marker: context.marker, dispatchNo: fixture.dispatchNo, + status: 'CHECKING', commands: [], rejections: [], snapshots: {}, syntheticTestMaterials: true } + const request = async (method, target, body, label, reject = false) => { + if (method !== 'GET') { proof.commands.push({ method, path: target, body: clone(body) }); await context.checkpoint() } + const response = await context.request(method, target, body) + if (reject) { + const denied = [200, 409].includes(response.http) && ['A0409', '409'].includes(String(response.body?.code)) + context.assert(MODULE, label, response, denied, '', OPTIONS); assert(denied) + proof.rejections.push({ label, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) + return response + } + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data + } + const get = (target, label) => request('GET', target, undefined, label) + const snapshot = async label => { + const data = { main: await get(path, label), items: await get(`${path}/items`, label), route: await get(`${path}/route`, label), stock: [] } + for (const member of fixture.members) data.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })) + proof.snapshots[label] = data; await context.checkpoint(); return data + } + const initial = await snapshot('INITIAL_APPROVED_ZERO_PICK') + assert.equal(initial.main.stage, 'LOADING'); assert.equal(initial.main.reviewState, 'APPROVED') + assert.equal(Number(initial.main.counts.pickedPackageCount), 0); assert.equal(Number(initial.main.counts.shippedPackageCount), 0) + const key = label => `${context.marker}-REV-${label}` + let result = await request('POST', `${path}/return-to-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('RETURN'), reason: 'TEST零扫码显式退回改货改车' }, 'Zero-pick explicit return') + assert.equal(result.stage, 'BEFORE_LOADING') + let before = await snapshot('RETURNED') + assert.equal(before.main.reviewState, 'DRAFT') + await request('POST', `${path}/submit-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('STALE'), remark: context.marker }, 'Stale main version is rejected', true) + assert.deepEqual(await snapshot('AFTER_STALE'), before) + const submit = { recordVersion: before.main.recordVersion, idempotencyKey: key('SUBMIT-REJECT-ROUND'), remark: 'TEST第一重提轮次' } + result = await request('POST', `${path}/submit-review`, submit, 'Submit dispatch rejection round') + before = await snapshot('SUBMITTED') + const replay = await request('POST', `${path}/submit-review`, submit, 'Original submit key replays') + assert.equal(replay.replayed, true); assert.equal(String(replay.recordVersion), String(result.recordVersion)) + await request('POST', `${path}/submit-review`, { ...submit, remark: 'TEST同键异载荷不得复用' }, 'Changed payload with original key is rejected', true) + await request('POST', `${path}/submit-review`, { ...submit, recordVersion: before.main.recordVersion, idempotencyKey: key('DUPLICATE-SUBMIT') }, 'New-key duplicate submit is rejected', true) + assert.deepEqual(await snapshot('AFTER_DUPLICATE_REJECTIONS'), before) + result = await request('POST', `${path}/dispatch-review`, { recordVersion: before.main.recordVersion, idempotencyKey: key('DISPATCH-REJECT'), decision: 'REJECTED', reason: 'TEST调度驳回' }, 'Dispatch reviewer rejects') + let detail = await get(path, 'Read dispatch rejection') + assert.equal(detail.reviewState, 'DISPATCH_REJECTED'); assert.equal(detail.reviews.dispatchReview.status, 'REJECTED') + result = await request('POST', `${path}/submit-review`, { recordVersion: detail.recordVersion, idempotencyKey: key('SUBMIT-LOADING-REJECT'), remark: 'TEST驳回重提' }, 'Resubmit after dispatch rejection') + result = await request('POST', `${path}/dispatch-review`, { recordVersion: result.recordVersion, idempotencyKey: key('DISPATCH-APPROVE'), decision: 'APPROVED', reason: 'TEST调度通过' }, 'Dispatch approves new round') + result = await request('POST', `${path}/loading-review`, { recordVersion: result.recordVersion, idempotencyKey: key('LOADING-REJECT'), decision: 'REJECTED', reason: 'TEST理货驳回' }, 'Loading reviewer rejects') + detail = await get(path, 'Read loading rejection') + assert.equal(detail.reviewState, 'LOADING_REJECTED'); assert.equal(detail.reviews.loadingReview.status, 'REJECTED') + const second = fixture.members[1] + result = await request('POST', `${path}/items/${encodeURIComponent(second.dispatchItemId)}/remove`, { + recordVersion: detail.recordVersion, idempotencyKey: key('REMOVE-SECOND'), reason: 'TEST零扫码先移出一票再真实重选' }, 'Remove only unpicked second member') + const removed = await snapshot('SECOND_REMOVED') + assert.equal(Number(removed.main.counts.packageCount), 1); assert.equal(removed.items.items.length, 1) + assert(removed.route.stops.every(stop => stop.destinationKey !== second.destination.key)) + const candidates = await get(`${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: second.incomingNo })}`, 'Read freshly released source candidate') + const choices = candidates.candidates?.filter(row => id(row.customerId) === second.customerId && id(row.selection?.warehousePalletId) === second.palletId && row.palletNo === second.palletNo) + assert.equal(choices?.length, 1); const choice = choices[0] + assert(choice.selection && choice.sourceVersion && choice.blockReasons?.length === 0 && Number(choice.availablePackages) === 1) + result = await request('POST', `${path}/items`, { recordVersion: result.recordVersion, idempotencyKey: key('ADD-SECOND'), + cargo: [{ ...choice.selection, warehousePalletId: second.palletId, sourceVersion: choice.sourceVersion }], batchAllocations: [] }, 'Re-add exact released current source') + const added = await snapshot('SECOND_RESTORED') + assert.equal(added.items.items.length, 2); assert.equal(Number(added.main.counts.packageCount), 2) + const replacement = added.items.items.filter(row => id(row.customerId) === second.customerId && id(row.warehousePalletId) === second.palletId) + assert.equal(replacement.length, 1); second.dispatchItemId = id(replacement[0].dispatchItemId) + const nextPlate = `FT-${context.marker.slice(-12)}-R2` + const vehicle = await request('POST', `${ROOT}/master-data/vehicles`, { plateNumber: nextPlate, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, + mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, + remark: `${context.marker} TEST零扫码改派自有车辆` }, 'Create owned replacement TEST vehicle') + const update = { recordVersion: result.recordVersion, idempotencyKey: key('CHANGE-VEHICLE'), vehicle: { mode: 'MASTER', vehicleId: id(vehicle.id) }, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST零扫码改车需两级重审` } + result = await request('PUT', path, update, 'Change resources before re-review') + detail = await get(path, 'Resource edit invalidates old approvals') + assert.equal(detail.reviewState, 'DRAFT'); assert.equal(id(detail.vehicleInfo.vehicle.vehicleId), id(vehicle.id)) + assert.notEqual(id(vehicle.id), fixture.tripProof.vehicleId) + for (const [action, suffix] of [['submit-review', 'FINAL-SUBMIT'], ['dispatch-review', 'FINAL-DISPATCH'], ['loading-review', 'FINAL-LOADING']]) { + result = await request('POST', `${path}/${action}`, { recordVersion: result.recordVersion, idempotencyKey: key(suffix), + ...(action === 'submit-review' ? { remark: 'TEST变更后重审' } : { decision: 'APPROVED', reason: 'TEST新依据重审通过' }) }, `Final new round ${action}`) + } + const final = await snapshot('REAPPROVED_ZERO_PICK') + assert.equal(final.main.stage, 'LOADING'); assert.equal(final.main.reviewState, 'APPROVED') + assert(Number(final.main.reviews.dispatchReview.reviewRound) > Number(initial.main.reviews.dispatchReview.reviewRound)) + assert.equal(final.main.reviews.dispatchReview.reviewRound, final.main.reviews.loadingReview.reviewRound) + assert.deepEqual(final.stock, initial.stock, 'All zero-pick review and cargo/resource edits preserve complete inventory') + const fleet = await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`, 'Read same main updated trip resources and route') + assert.equal(fleet.trip.tripNo, fixture.tripNo); assert.equal(id(fleet.trip.vehicleId), id(vehicle.id)) + assert.equal(fleet.dispatches.length, 1); assert.equal(fleet.dispatches[0].dispatchNo, fixture.dispatchNo) + Object.assign(fixture.tripProof, { marker: nextPlate, vehicleId: id(vehicle.id), vehicle: final.main.vehicleInfo.vehicle, + vehicleInfo: final.main.vehicleInfo, driver: final.main.vehicleInfo.driver, updateRequest: update, trip: fleet.trip, + stops: fleet.trip.stops, fleetRecordVersion: fleet.trip.recordVersion, recordVersion: final.main.recordVersion }) + proof.membersAfterEdit = clone(fixture.members); proof.finalVehicleInfo = clone(final.main.vehicleInfo) + proof.status = 'VERIFIED'; await context.checkpoint(); return proof +} + +export async function verifyReviewExtension(context, fixture, options) { + await exerciseReviewLifecycle(context, fixture, options) + return verifyDualDestination(context, fixture, options) +} + +/** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */ +export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) { + assert.equal(prior.success, false) + assert.equal(prior.error, 'Native main/items/PDA retain both true customers and physical pallets') + assert.equal(prior.createdResources.dispatchWorkflowReviews.status, 'VERIFIED') + assert.equal(prior.createdResources.fleetWriteBoundary, undefined) + const old = prior.createdResources.fleetCrossCustomer, path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + assert.equal(old.dispatchNo, fixture.dispatchNo); assert.equal(old.marker, context.marker) + assert(old.requests.filter(row => row.label === 'Attach receipt to independently complete this delivery').length === 2) + assert(old.requests.every(row => row.status === 'RESPONDED')) + const get = async target => { + const response = await context.request('GET', target) + const data = context.apiSuccess(MODULE, 'Explicit terminal reconciliation read only', response, '', OPTIONS) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data + } + const current = { trip: await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`), dispatch: await get(path), + items: await get(`${path}/items`), task: null, members: [] } + const pda = await context.request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + assert.equal(pda.http, 200); assert.equal(String(pda.body?.code), '200'); current.task = pda.body.data + assert.equal(current.dispatch.stage, 'DELIVERED'); assert.equal(current.dispatch.status, 'ACTIVE') + for (const value of [current.items, current.task]) { + assert.equal(value.dispatchNo, fixture.dispatchNo); assert.equal(value.stage, 'DELIVERED') + assert.equal(id(value.recordVersion), id(current.dispatch.recordVersion)); assert.equal(value.items.length, 2) + } + for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) assert.equal(Number(current.dispatch.counts[field]), 2) + assert.equal(current.trip.trip.tripNo, fixture.tripNo) + assert.equal(id(current.trip.trip.vehicleId), fixture.tripProof.vehicleId); assert.equal(id(current.trip.trip.driverId), fixture.tripProof.driverId) + assert.equal(current.trip.dispatches.length, 1); assert.equal(current.trip.dispatches[0].dispatchNo, fixture.dispatchNo) + const extension = context.createdResources.dispatchWorkflowExtensions = clone(prior.createdResources.dispatchWorkflowExtensions) + extension.status = 'CHECKING'; extension.explicitTerminalContinuation = true; extension.priorFailurePreserved = true + const allFeeIds = new Set() + for (const [index, member] of fixture.members.entries()) { + const oldMember = old.members[index] + assert.equal(member.customerId, oldMember.customerId); assert.equal(member.dispatchItemId, oldMember.dispatchItemId) + for (const rows of [current.items.items, current.task.items]) { + const matches = rows.filter(row => id(row.dispatchItemId) === member.dispatchItemId) + assert.equal(matches.length, 1); assert.equal(id(matches[0].customerId), member.customerId) + assert.equal(matches[0].palletNo, member.palletNo); assert.equal(Number(matches[0].pickedPackages), 1) + } + const suffix = new URLSearchParams({ customerId: member.customerId }) + const outbound = await get(`${ROOT}/outbound-orders/${encodeURIComponent(oldMember.outboundNo)}?${suffix}`) + const delivery = await get(`${ROOT}/delivery-orders/${encodeURIComponent(oldMember.deliveryNo)}?${suffix}`) + assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED') + assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED') + assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) + const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length) + for (const fee of delivery.fees) { + assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) + assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + } + const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }) + assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory') + current.members.push({ outbound, delivery, stock }) + } + assert.deepEqual(await get(path), current.dispatch, 'Terminal state changed during reconciliation') + const verified = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, + tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, members: clone(old.members), status: 'VERIFIED', snapshots: { FINAL: current }, + continuation: 'TERMINAL_READONLY_AFTER_CONFIRMED_SIGNATURES', priorError: prior.error, + gateProof: old.gateProof, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' } + extension.status = 'VERIFIED'; await context.checkpoint(); return verified +} diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs new file mode 100644 index 000000000..b41fd7f62 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, assertBolPages } from './dispatch-workflow-extension-acceptance.mjs' +import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs' + +test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => { + const plan = await runDispatchExtensions(parseExtensionArguments([]), {}) + assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5) + assert.equal(extensionMatrix.find(row => row.scenario === 'relabel').readiness, 'REQUIRES_FORMAL_RELABEL_FIXTURE') +}) +test('execution requires fresh explicit TEST scope and declared synthetic load, never coerces a plan', () => { + const args = ['--execute', '--environment', 'TEST', '--run-id', 'EXT1', '--scenario', 'dual-destination', '--warehouse-id', '10', + '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1'] + assert.equal(parseExtensionArguments(args).execute, true) + for (const [from, to] of [['TEST', 'DEV'], ['EXT1', 'plan'], ['dual-destination', 'relabel'], ['10', ''], ['1', '0']]) + assert.throws(() => parseExtensionArguments(args.map(value => value === from ? to : value))) + assert.throws(() => parseExtensionArguments([...args, '--execute'])) +}) +test('terminal continuation requires an explicit original report hash and is never an implicit replay', () => { + assert.throws(() => parseExtensionArguments(['--scenario', 'review-terminal'])) + assert.throws(() => parseExtensionArguments(['--resume-report', 'reports/old/report.json'])) + const config = parseExtensionArguments(['--execute', '--environment', 'TEST', '--run-id', 'EXTCLOSE1', '--scenario', 'review-terminal', + '--warehouse-id', '10', '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1', + '--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)]) + assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64)) +}) +test('actual group owner controls distinct cents even when server group ordering differs', () => { + const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }] + const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey, + fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) } + const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT') + assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25]) + assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775) + assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL') + assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成'))) +}) +test('unknown customers, repeated groups and missing real required fees fail before transport', () => { + const members = [{ customerId: 'A' }, { customerId: 'B' }] + for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }], + [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) { + const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) } + assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT')) + } + assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] }, + [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT')) +}) +test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => { + const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true }, + { customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }], + tripProof: { stops: [{}, { destinationKey: 'FBA:ONE' }, { destinationKey: 'FBA:TWO' }] } } + assert.doesNotThrow(() => assertDualDestinationFixture(fixture)) + for (const mutate of [f => { f.members[1].customerId = 'A' }, f => { f.members[1].destination.key = 'FBA:ONE' }, + f => { f.tripProof.stops.pop() }]) { const bad = structuredClone(fixture); mutate(bad); assert.throws(() => assertDualDestinationFixture(bad)) } +}) +test('fee conservation uses integer cents and rejects unsafe, negative or overprecision amounts', () => { + assert.equal(feeCents('31.25'), 3125); assert.equal(feeCents(47.75), 4775) + for (const value of [null, false, -1, '1e2', '0.001', Number.MAX_SAFE_INTEGER]) assert.throws(() => feeCents(value)) +}) + +for (const unknown of [false, true]) test(`bounded two-actor race ${unknown ? 'preserves unknown result and stops' : 'starts both requests before either returns and conserves inventory'}`, async () => { + const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA_TOKEN', members: [{ customerId: 'A' }, { customerId: 'B' }] } + const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, + checkpoint: async () => {}, assert: () => {} } + const acknowledgement = { recordVersion: '8', result: { confirmedLoad: { pallets: ['P1', 'P2'] } } } + const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL', measurements: ['P1', 'P2'] }, requests = [], waiting = [] + const send = async (method, path, payload, token) => { + requests.push({ method, path, payload: structuredClone(payload), token }) + if (method === 'GET') return { http: 200, body: { code: '200', data: { immutable: path } } } + return new Promise((resolve, reject) => { + waiting.push({ resolve, reject, token }) + if (waiting.length === 2) for (const entry of waiting) { + if (entry.token) entry.resolve({ http: 200, body: { code: '200', data: { ...acknowledgement, replayed: true } } }) + else if (unknown) entry.reject(Error('Unknown write outcome; preserve original key')) + else entry.resolve({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员' } }) + } + }) + } + const execute = () => verifyLoadingReplayRace(context, fixture, { readInventory: async scope => ({ ...scope, available: 0, picked: 1 }) }, send, body, acknowledgement) + if (unknown) await assert.rejects(execute(), /Unknown write outcome/) + else await execute() + assert.equal(waiting.length, 2) + assert.equal(requests.filter(row => row.method === 'POST').length, 2) + assert(requests.filter(row => row.method === 'POST').every(row => JSON.stringify(row.payload) === JSON.stringify(body))) + const proof = context.createdResources.dispatchWorkflowCompetition.loadingReplayRace + assert.equal(proof.status, unknown ? 'UNKNOWN_RESULT' : 'VERIFIED') + assert.equal(proof.firstExecutionRace, 'NOT_TESTED') + if (!unknown) assert.deepEqual(proof.before, proof.after) +}) + +test('BOL PDF evidence enforces empty/same/different appointment grouping and actual quantity conservation', () => { + const fixture = { members: [{ palletNo: 'PALLET_A', incomingNo: 'PALLET_A' }, { palletNo: 'PALLET_B', incomingNo: 'PALLET_B' }] } + const pdf = (isa, pallets, total) => `BILL OF LADING\nISA #: ${isa}\n${pallets.join('\n')}\n总箱数 / Total CTNS:\n${total}\nDriver signature` + assert.equal(assertBolPages([pdf('', ['PALLET_A', 'PALLET_B'], 2)], fixture, { appointments: [] }, 'bol-empty').totalPackages, 2) + assert.equal(assertBolPages([pdf('ISA1', ['PALLET_A', 'PALLET_B'], 2)], fixture, + { appointments: [{ data: { confirmationNo: 'ISA1' } }] }, 'bol-same').pageCount, 1) + const appointments = { appointments: [{ data: { confirmationNo: 'ISA1' } }, { data: { confirmationNo: 'ISA2' } }] } + const pages = [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA2', ['PALLET_B'], 1)] + assert.equal(assertBolPages(pages, fixture, appointments, 'bol-different').pageCount, 2) + for (const bad of [[pdf('ISA1', ['PALLET_A'], 2), pdf('ISA2', ['PALLET_B'], 2)], + [pdf('ISA1', ['PALLET_A', 'PALLET_B'], 1), pdf('ISA2', ['PALLET_B'], 1)], + [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]]) + assert.throws(() => assertBolPages(bad, fixture, appointments, 'bol-different')) +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 699e21fc4..8289b1741 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -26,7 +26,7 @@ function model(fault = '', countPatch = {}) { let native const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0, - destination: { key: 'FBA:FBA' } })) + destination: { key: `FBA:${transfer.destinationWarehouseCode}` } })) const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, @@ -51,6 +51,8 @@ function model(fault = '', countPatch = {}) { if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken) if (path.endsWith('/warehouse-admin/10')) return response(origin) if (path.endsWith('/warehouse-admin/20')) return response(destination) + if (path.endsWith('/warehouse-admin/21')) return response({ ...destination, + warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } }) if (path === '/api/platform/v1/iam/tenants') { const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n) const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark } @@ -80,7 +82,7 @@ function model(fault = '', countPatch = {}) { const transferNo = `IN${transfers.length}` const itemId = `FORECAST-ITEM${transfers.length}` const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId, - status: 'DRAFT', packageCount: 1, pallets: [], + status: 'DRAFT', packageCount: 1, pallets: [], destinationWarehouseCode: body.destinationWarehouseCode, items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }], cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST', transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] } @@ -141,7 +143,7 @@ function model(fault = '', countPatch = {}) { const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n] assert.equal(transfer.status, 'READY_FOR_DISPATCH') return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, - warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' }, + warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: `FBA:${transfer.destinationWarehouseCode}` }, sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] }) } if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { @@ -152,10 +154,11 @@ function model(fault = '', countPatch = {}) { counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {}, documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} } trip.dispatches = [{ dispatchNo: native.dispatchNo }] - Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] }) + Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, ...[...new Set(transfers.map(row => row.destinationWarehouseCode))] + .map(code => ({ name: code, destinationKey: `FBA:${code}` }))] }) dispatches.push(native); return response(native) } - if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) } + if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) } if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 }) @@ -270,6 +273,26 @@ test('native Jackson Long count strings build the same complete one-main fixture assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo)) }) +test('explicit per-member destinations are read before writes and preserved in both receipts and route', async () => { + const m = model(); m.options.destinationWarehouseIds = ['20', '21'] + const fixture = await createFleetCrossCustomerFixture(m.context, m.options) + assert.deepEqual(fixture.members.map(row => row.destination.key), ['FBA:FBA', 'FBA:FBA2']) + assert.deepEqual(m.trip.trip.stops.slice(1).map(row => row.destinationKey), ['FBA:FBA', 'FBA:FBA2']) + const secondDestination = m.calls.findIndex(row => row.raw.includes('/warehouse-admin/21')) + const firstWrite = m.calls.findIndex(row => row.method !== 'GET') + assert.ok(secondDestination >= 0 && secondDestination < firstWrite) + assert.deepEqual(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/merchant/v1/last-leg/warehouse-transfers') + .map(row => row.body.destinationWarehouseId), ['20', '21']) +}) + +test('malformed member destination scope fails without network', async () => { + for (const destinations of [[], ['20'], ['20', '21', '22'], ['21', '20'], ['20', '']]) { + const m = model(); m.options.destinationWarehouseIds = destinations + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Two explicit/) + assert.equal(m.calls.length, 0) + } +}) + test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => { for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) { assert.equal(safeFixtureCount(value), expected) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index e617cea61..df6ad6966 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -32,11 +32,19 @@ export const preparationContract = Object.freeze({ }) /** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */ -export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) { +export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds, + shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) { if (!execute) return preparationContract if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function' || ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function')) throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader') + const memberDestinations = destinationWarehouseIds ?? [destinationWarehouseId, destinationWarehouseId] + if (shipmentReferences !== undefined && (!Array.isArray(shipmentReferences) || shipmentReferences.length !== 2 + || shipmentReferences.some(value => typeof value !== 'string' || !value.trim()))) throw Error('Two explicit TEST shipment references required') + if (prepareBatchAllocations !== undefined && typeof prepareBatchAllocations !== 'function') throw Error('Appointment preparation must be callable') + if (!Array.isArray(memberDestinations) || memberDestinations.length !== 2 + || memberDestinations.some(value => !id(value)) || id(memberDestinations[0]) !== id(destinationWarehouseId)) + throw new Error('Two explicit member destinations must start with the primary destination') context.createdResources ??= {} if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly') const proof = context.createdResources.fleetCrossCustomerFixture = { @@ -106,18 +114,23 @@ export async function createFleetCrossCustomerFixture(context, { execute = false try { // Explicit IDs are re-read through the public master-data API before any fixture writes. const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse') - const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination') const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId) && id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type && detail.warehouse.warehouseCode && detail.warehouse.warehouseName check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS') - && origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true) - && validMaster(destination, destinationWarehouseId, 'FBA')) - const address = destination.addresses?.find(row => row.addressLine1) - check('FBA destination has a real address', Boolean(address)) - const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district, - address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') + && origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true)) + const destinations = new Map() + for (const destinationId of [...new Set(memberDestinations.map(id))]) { + const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationId)}?tenantId=0`, undefined, 'Verify FBA destination') + check('Each explicit destination is a public active FBA master', validMaster(destination, destinationId, 'FBA')) + const address = destination.addresses?.find(row => row.addressLine1) + check('FBA destination has a real address', Boolean(address)) + destinations.set(destinationId, { destination, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.district, + address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') }) + } for (const suffix of ['A', 'B']) { + const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1]) + const { destination, deliveryAddress } = destinations.get(memberDestinationId) const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false } proof.members.push(member) // Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot. @@ -147,11 +160,12 @@ export async function createFleetCrossCustomerFixture(context, { execute = false const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken) check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId) const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId), - fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId), + fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: memberDestinationId, destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName, deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000), needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`), items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] } + if (shipmentReferences) createBody.fbaShipmentNo = shipmentReferences[proof.members.length - 1] let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken) member.incomingNo = incoming?.transferNo check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo @@ -234,6 +248,20 @@ export async function createFleetCrossCustomerFixture(context, { execute = false proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId), vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] } + if (prepareBatchAllocations) { + proof.createDispatchRequest.batchAllocations = await prepareBatchAllocations(proof.members) + if (!Array.isArray(proof.createDispatchRequest.batchAllocations)) throw Error('Formal appointment allocations must be explicit') + for (const member of proof.members) { + const current = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`, + undefined, 'Refresh actual candidate after formal appointment creation') + const matches = current.candidates?.filter(row => id(row.customerId) === member.customerId && id(row.selection?.warehousePalletId) === member.palletId) + check('Appointment preparation preserves exact received physical source', matches?.length === 1 && current.complete === true + && matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].blockReasons?.length === 0) + member.selection = matches[0].selection + } + proof.createDispatchRequest.cargo = proof.members.map(member => member.selection) + await context.checkpoint?.() + } const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers') check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE' && created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index a98dc4500..ce1b26415 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -47,7 +47,14 @@ export async function dispatchEvidenceContext(context) { } export function query(path, values) { const params = new URLSearchParams() - for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value)) + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value)) { + for (const item of value) if (item !== undefined && item !== null && item !== '') params.append(key, String(item)) + continue + } + params.set(key, String(value)) + } return `${path}?${params}` } export function commandKey(marker, namespace, action) { @@ -112,8 +119,8 @@ export function dispatchIo(context, module, proof, namespace) { const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false } if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker') const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() } - const check = (label, response, passed) => { - context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) + const check = (label, response, passed, detail = '') => { + context.assert?.(module, label, response, passed, `${detail ? `${detail}; ` : ''}marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) if (!passed) throw Error(`${namespace}: ${label}`) } const send = async (method, path, body, label) => { @@ -136,7 +143,8 @@ export function dispatchIo(context, module, proof, namespace) { } const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data } const key = action => commandKey(context.marker, namespace, action) - return { check, send, command, key } + return { check, send, command, key, + planIds: (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) } } export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy } export async function replay(io, path, body, result, label, method = 'POST') { @@ -145,7 +153,9 @@ export async function replay(io, path, body, result, label, method = 'POST') { return again } export async function candidatePage(io, warehouseId, extra = {}) { - const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选') + const planIds = extra.planIds ?? io?.planIds + const params = { warehouseId, sourceStage: 'RECEIVED', ...(Array.isArray(planIds) && planIds.length ? { planIds } : {}), ...extra } + const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, params), undefined, '读取同仓原生候选') io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates)) return result.candidates } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index fbd696dd1..8e2151f94 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -251,38 +251,45 @@ async function reusablePallets(context, warehouseId) { if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey) const pallets = [] - if (allowAnyExistingPallet) { - const response = await context.request('GET', - `/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`) - const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response, - `warehouseId=${warehouseId}`, OPTIONS) - const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey - && asId(item?.id) && String(item?.palletCode || '').trim()) - context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0, - `warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS) - if (candidates.length === 0) return null - const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) })) - reusablePalletsByWarehouse.set(scopedCacheKey, selected) - return selected - } - for (const palletCode of REUSABLE_PALLET_CODES) { - const response = await context.request('GET', - `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`) - const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response, - `warehouseId=${warehouseId}`, OPTIONS) - const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey - && String(item?.palletCode || '') === palletCode && asId(item?.id)) - context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, - `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS) - if (!exact) { - context.assert(MODULE, '固定称重托盘主数据完整', response, false, - `warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS) - return null + const missingFixed = [] + if (!allowAnyExistingPallet) { + for (const palletCode of REUSABLE_PALLET_CODES) { + const response = await context.request('GET', + `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`) + const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response, + `warehouseId=${warehouseId}`, OPTIONS) + const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey + && String(item?.palletCode || '') === palletCode && asId(item?.id)) + context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, + `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS) + if (!exact) { + missingFixed.push(palletCode) + continue + } + pallets.push({ id: asId(exact.id), code: palletCode }) } - pallets.push({ id: asId(exact.id), code: palletCode }) + if (missingFixed.length === 0) { + reusablePalletsByWarehouse.set(scopedCacheKey, pallets) + return pallets + } + // 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。 + context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`, + 'fixture', { blocking: false }) } - reusablePalletsByWarehouse.set(scopedCacheKey, pallets) - return pallets + + const response = await context.request('GET', + `/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`) + const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response, + `warehouseId=${warehouseId}`, OPTIONS) + const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey + && asId(item?.id) && String(item?.palletCode || '').trim()) + context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0, + `warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS) + if (candidates.length === 0) return null + const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) })) + reusablePalletsByWarehouse.set(scopedCacheKey, selected) + return selected } function randomlyReusePalletIds(pallets, palletCount, randomValue) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index ee62dac6a..9aab796ab 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -48,7 +48,7 @@ function server(fault = '', initial = false) { if (path === `${BASE}/candidates`) { assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED') const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) - for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) + for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' }) } if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index a7804bb7b..fa36bebb4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -129,7 +129,12 @@ async function prepareDraft(context, io, candidates) { const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken }) io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) const hidden = await candidatePage(io, candidates[0].warehouseId) - io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + io.check('草稿锁为软占用不阻断其他草稿候选', null, + candidates.every(c => hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)), + `hidden=${JSON.stringify(candidates.map(c => ({ key: c.sourceKey, reasons: hidden.filter(a => a.sourceKey === c.sourceKey).map(a => a.blockReasons) }))).slice(0, 600)}`) + context.addCoverageGap?.(MODULE, 'dispatch-occupancy', '正式排车活动占用阻断', + '草稿锁是软占用,不阻断其他草稿;正式排车占用阻断由 grouped 正式整车转换与 removeControl 回查覆盖', + 'not-implemented', { blocking: false }) return draft } async function createRelabel(context, io, proof) { @@ -152,8 +157,10 @@ export async function run(context) { const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] } try { const fixture = context.createdResources.phase4OverseasPda + const dispatchPlanIds = () => (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) markerCandidates(context, []) let io = dispatchIo(context, MODULE, proof, 'MAIN') + io.planIds = dispatchPlanIds() const page = await candidatePage(io, fixture.overseasWarehouseId) const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page)) if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本') @@ -161,6 +168,7 @@ export async function run(context) { ;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c])) context = await dispatchEvidenceContext(context) io = dispatchIo(context, MODULE, proof, 'MAIN') + io.planIds = dispatchPlanIds() proof.removalDispatchNo = await removeControl(context, io, selected[0]) const grouped = await verifyGroupedDispatch(context, MODULE, selected) if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 765e56c5e..dcd59091e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -429,7 +429,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128), newSkuCode, labelRequirement: context.marker, - cartonMark: `CM-${String(context.marker).slice(-28)}-${index + 1}`, + cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`, fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null, destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null, operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 1b6327edc..a829c9abc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -374,6 +374,11 @@ export async function disablePicker(context, picker, index) { function rememberGeneratedPlans(context, plans) { if (!Array.isArray(plans)) return for (const plan of plans) { + const planId = id(plan?.id || plan?.planId) + if (longId(planId)) { + const ids = context.createdResources.phase4OverseasPda.generatedPlanIds ??= [] + if (!ids.includes(planId)) ids.push(planId) + } const inboundCode = id(plan?.lines?.[0]?.inboundCode) if (!nonBlank(inboundCode)) continue const generated = Array.isArray(plan?.pallets) ? plan.pallets : [] @@ -1172,6 +1177,7 @@ export async function run(context) { completedContainerFacts: [], overseasWarehouseId: null, overseasWarehouseName: null, generatedPalletsByInbound: {}, + generatedPlanIds: [], cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } const merchantToken = await loginMerchantForNegativeProbe(context) diff --git a/scripts/dispatch-workflow-extensions-test-env.mjs b/scripts/dispatch-workflow-extensions-test-env.mjs new file mode 100644 index 000000000..3032f5d01 --- /dev/null +++ b/scripts/dispatch-workflow-extensions-test-env.mjs @@ -0,0 +1,172 @@ +import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { runCrossCustomer } from './fleet-cross-customer-test-env.mjs' +import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' +import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import { discoverSecondDestination, verifyDualDestination, verifyReviewExtension, verifyCompetitionExtension, prepareBolAppointments, verifyBolExtension, reconcileReviewTerminal, extensionMatrix } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +export function parseExtensionArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'dual-destination', warehouseId: '', destinationWarehouseId: '', pdaEnvFile: '', syntheticPalletSpaces: null, resumeReport: '', resumeReportSha256: '', pdfPython: '' } + const seen = new Set() + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (['--plan', '--execute'].includes(name)) { + if (seen.has('mode')) throw Error('Duplicate mode'); seen.add('mode'); config.execute = name === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--scenario': 'scenario', '--warehouse-id': 'warehouseId', + '--destination-warehouse-id': 'destinationWarehouseId', '--pda-env-file': 'pdaEnvFile', '--synthetic-pallet-spaces': 'syntheticPalletSpaces', + '--resume-report': 'resumeReport', '--resume-report-sha256': 'resumeReportSha256', '--pdf-python': 'pdfPython' }[name] + if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported/duplicate argument') + seen.add(name); config[field] = args[++index] + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)) throw Error('Explicit TEST/fresh run ID required') + if (config.execute && (!['dual-destination', 'reviews', 'competition', 'review-terminal', 'bol-empty', 'bol-same', 'bol-different'].includes(config.scenario) || config.runId === 'plan' || !config.pdaEnvFile + || !/^[1-9]\d*$/.test(config.warehouseId) || !/^[1-9]\d*$/.test(config.destinationWarehouseId) || config.syntheticPalletSpaces !== '1')) + throw Error('Ready scenario, explicit warehouse/destination/PDA config and synthetic pallet-spaces=1 required') + if (config.scenario === 'review-terminal' && (!config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) throw Error('Explicit frozen review report required') + if (config.scenario !== 'review-terminal' && (config.resumeReport || config.resumeReportSha256) + && (!config.scenario.startsWith('bol-') || !config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) + throw Error('Resume options require an explicit frozen owned checkpoint') + if (config.execute && config.scenario.startsWith('bol-') && !config.pdfPython) throw Error('Explicit PDF reader runtime required') + return config +} +export async function runDispatchExtensions(config, env = process.env) { + if (!config.execute) return { execute: false, environment: 'TEST', matrix: extensionMatrix, + limits: 'No deploy, SQL writes, permission edits, deletion, old manual main mutation, blind retries or uncontrolled fault injection.' } + if (env.CHONGCHONG_ENV !== 'test') throw Error('Only the explicitly loaded TEST configuration is accepted') + const bol = config.scenario.startsWith('bol-') + if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 }) + let prior + if (config.scenario === 'review-terminal' || bol && config.resumeReport) { + const sourcePath = resolve(ROOT, config.resumeReport) + const prefix = bol ? 'fleet-cross-DISPATCH20261020EXTBOL' : 'fleet-cross-DISPATCH20261020EXTREV' + if (!sourcePath.startsWith(join(ROOT, 'reports', prefix))) throw Error('Only this extension-owned frozen report can be reconciled') + const bytes = readFileSync(sourcePath) + if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed') + prior = JSON.parse(bytes) + if (bol) { + if (prior.success !== false || prior.error !== 'Expected values to be strictly equal:\n\n0 !== 1\n' + || prior.createdResources?.dispatchWorkflowBol?.status !== 'CHECKING' || prior.createdResources.dispatchWorkflowBol.scenario !== config.scenario + || prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer + || prior.createdResources?.fleetWriteBoundary || prior.createdResources?.fleetTrips?.[0]?.closure) + throw Error('Not the bounded PDF long-pallet-code assertion checkpoint') + } else if (prior.success !== false || prior.error !== 'Native main/items/PDA retain both true customers and physical pallets' + || prior.createdResources?.dispatchWorkflowReviews?.status !== 'VERIFIED' || prior.createdResources?.fleetWriteBoundary + || prior.createdResources?.fleetTrips?.[0]?.closure) throw Error('Not the bounded known terminal-read race') + for (let number = 1; number <= prior.requestCount; number++) + if (JSON.parse(readFileSync(join(dirname(sourcePath), 'requests', `${number}.json`))).stage !== 'RESPONSE') throw Error('Prior request outcome uncertain; terminal mode forbidden') + } + const reportDirectory = join(ROOT, 'reports', `fleet-cross-${config.runId}`) + const intentPath = join(ROOT, 'reports', `dispatch-workflow-${config.runId}.intent.json`) + if (existsSync(reportDirectory) || existsSync(intentPath)) throw Error('Previous attempt exists; retain original requests and reconcile, never re-enter') + const pda = Object.fromEntries(readFileSync(config.pdaEnvFile, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/) + .filter(line => /^[A-Z][A-Z0-9_]*=/.test(line)).map(line => { const separator = line.indexOf('='); + return [line.slice(0, separator), line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')] })) + if (!pda.PDA_USERNAME || !pda.PDA_PASSWORD) throw Error('Missing authorized existing overseas PDA credentials') + const files = ['scripts/dispatch-workflow-extensions-test-env.mjs', 'scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs', + 'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/fleet-cross-customer-test-env.mjs', + 'scripts/admin-test-env-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'] + const seal = Object.fromEntries(files.map(path => [path, hash(readFileSync(join(ROOT, path)))])) + const stable = () => { for (const [path, expected] of Object.entries(seal)) if (hash(readFileSync(join(ROOT, path))) !== expected) throw Error(`Source drift: ${path}`) } + mkdirSync(join(ROOT, 'reports'), { recursive: true }) + writeFileSync(intentPath, JSON.stringify({ runId: config.runId, scenario: config.scenario, environment: 'TEST', seal, + createdAt: new Date().toISOString(), syntheticTestMaterials: true, physicalMeasurementVerified: false, hardwareVerified: false, + originalManualMain: 'READ_ONLY_NOT_USED', resumeReportSha256: prior ? config.resumeReportSha256 : null, + midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }, null, 2), { flag: 'wx' }) + const result = await runCrossCustomer(config, { + env: { ...env, DISPATCH_EXISTING_PDA_PASSWORD: pda.PDA_PASSWORD }, + contextFactory: options => { const context = new AcceptanceRun(options); if (prior) context.marker = prior.marker; return context }, + pickerCreate: async context => { + context.createdResources.extensionIdentity = { mode: 'EXISTING_TEST_PDA_OWNED_NEW_SESSION', accountMutation: false } + const response = await context.request('POST', '/api/auth/login', { loginId: pda.PDA_USERNAME, password: pda.PDA_PASSWORD }, '') + if (response.http !== 200 || String(response.body?.code) !== '200' || !response.body.data?.accessToken || !response.body.data.refreshToken) throw Error('Existing PDA login failed') + return { membershipId: null, password: pda.PDA_PASSWORD, token: response.body.data.accessToken, refreshToken: response.body.data.refreshToken } + }, + pickerDisable: async () => { throw Error('Existing identity must never be disabled') }, + prepare: async (context, options) => { + stable(); context.createdResources.extensionSourceSeal = seal + if (prior) { + const tripProof = structuredClone(prior.createdResources.fleetTrips[0]) + context.createdResources.fleetTrips = [tripProof] + context.createdResources[bol ? 'explicitBolCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256, + originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false } + if (bol) { + context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments) + context.createdResources.fleetCrossCustomerFixture = structuredClone(prior.createdResources.fleetCrossCustomerFixture) + return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo, + warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, + members: structuredClone(prior.createdResources.fleetCrossCustomerFixture.members), + loadingMeasurements: structuredClone(prior.createdResources.extensionSyntheticLoadingInput) } + } + const members = prior.createdResources.fleetCrossCustomerFixture.members.map(member => { + const match = prior.createdResources.fleetCrossCustomer.members.find(row => row.customerId === member.customerId) + if (!match) throw Error('Original receipt/member attribution missing') + return { ...structuredClone(member), ...structuredClone(match) } + }) + return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo, + warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, members } + } + if (bol) { + const master = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(config.destinationWarehouseId)}?tenantId=0`) + const timezone = master.body?.data?.warehouse?.timezone + if (master.http !== 200 || String(master.body?.code) !== '200' || !/^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timezone || '')) + throw Error('BOL appointment fixtures require an existing FBA destination with an actual regional timezone before any fixture write') + } + const secondId = bol ? config.destinationWarehouseId : await discoverSecondDestination(context, config.destinationWarehouseId) + const fixture = await createFleetCrossCustomerFixture(context, { ...options, destinationWarehouseIds: [config.destinationWarehouseId, secondId], + ...(bol ? { shipmentReferences: ['A', 'B'].map(suffix => `TEST-FBA-${context.marker.slice(-12)}-${suffix}`), + prepareBatchAllocations: members => prepareBolAppointments(context, members, config.scenario, config.warehouseId) } : {}) }) + fixture.loadingMeasurements = { environment: 'TEST', marker: context.marker, dispatchNo: fixture.dispatchNo, syntheticTestMaterials: true, + physicalMeasurementVerified: false, hardwareVerified: false, pallets: fixture.members.map(member => ({ warehousePalletId: member.palletId, + palletSpaces: 1, evidence: [{ field: 'palletSpaces', value: 1, actual: true, sourceType: 'TEST_SYNTHETIC_LOADING_INPUT', + sourceBusinessNo: config.runId, observedAt: new Date().toISOString() }] })) } + context.createdResources.extensionSyntheticLoadingInput = fixture.loadingMeasurements + await context.checkpoint(); stable(); return fixture + }, + verify: async (...args) => { + stable() + if (prior && !bol) return reconcileReviewTerminal(...args, prior) + if (bol) { + const [context, fixture, options] = args + return verifyBolExtension(context, fixture, { ...options, bol: config.scenario, inspectBol: async () => { + const evidence = join(reportDirectory, 'evidence'), pdfPath = join(evidence, 'BOL.pdf'), journal = join(evidence, 'bol-http.json') + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/prints/bol` + const record = { method: 'GET', path, stage: 'INTENT', numberOfBinaryRequests: 1 } + writeFileSync(journal, JSON.stringify(record, null, 2), { flag: 'wx' }); await context.checkpoint() + const response = await context.requestBinary('GET', path) + Object.assign(record, { stage: 'RESPONSE', http: response.http, contentType: response.contentType, + requestId: response.requestId, traceId: response.traceId, bytes: response.bytes.length, sha256: hash(response.bytes) }) + writeFileSync(journal, JSON.stringify(record, null, 2)) + if (response.http !== 200 || !response.contentType.includes('application/pdf') || Buffer.from(response.bytes).subarray(0, 5).toString() !== '%PDF-') + throw Error('Actual BOL endpoint did not return a PDF; preserve response metadata') + writeFileSync(pdfPath, response.bytes, { flag: 'wx' }) + const result = JSON.parse(execFileSync(config.pdfPython, ['-c', + 'import json,sys; from pypdf import PdfReader; r=PdfReader(sys.argv[1]); print(json.dumps({"pages":[p.extract_text() for p in r.pages]}))', pdfPath], + { encoding: 'utf8', timeout: 30000 })) + writeFileSync(join(evidence, 'bol-pages.json'), JSON.stringify(result, null, 2), { flag: 'wx' }) + context.createdResources.dispatchWorkflowBol.pdf = { path: 'evidence/BOL.pdf', ...record, pageCount: result.pages.length } + await context.checkpoint(); return result.pages + } }) + } + return (config.scenario === 'reviews' ? verifyReviewExtension : config.scenario === 'competition' ? verifyCompetitionExtension : verifyDualDestination)(...args) + }, + finish: async (...args) => { stable(); return finishFleetTrip(...args) }, + }) + return { success: result.success, error: result.error, runId: config.runId, requestCount: result.requestCount, summary: result.summary, + sessionsClosed: result.sessionsClosed, scenario: config.scenario, extensionStatus: result.createdResources?.dispatchWorkflowExtensions?.status, + reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status, + competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status, + bolStatus: result.createdResources?.dispatchWorkflowBol?.status, + report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const result = await runDispatchExtensions(parseExtensionArguments(process.argv.slice(2))) + console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 +} diff --git a/scripts/local-check.ps1 b/scripts/local-check.ps1 index db47323b7..4f3c9d6ea 100644 --- a/scripts/local-check.ps1 +++ b/scripts/local-check.ps1 @@ -12,27 +12,42 @@ $ErrorActionPreference = 'Stop' if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) { throw 'local-check requires Windows and PowerShell 7 (pwsh).' } -# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。 +# 所有工作目录共用两个用户级锁;首槽保留原锁名,运行中的旧入口仍计入容量。 $stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks' [IO.Directory]::CreateDirectory($stateDirectory) | Out-Null -$lockPath = Join-Path $stateDirectory 'heavy.lock' -$ownerPath = Join-Path $stateDirectory 'owner.json' -$resultPath = Join-Path $stateDirectory 'last-result.json' -function Read-Owner { - try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' } +$slotDefinitions = @( + [pscustomobject]@{ number = 1; lock = (Join-Path $stateDirectory 'heavy.lock'); owner = (Join-Path $stateDirectory 'owner.json'); result = (Join-Path $stateDirectory 'last-result.json') }, + [pscustomobject]@{ number = 2; lock = (Join-Path $stateDirectory 'heavy-2.lock'); owner = (Join-Path $stateDirectory 'owner-2.json'); result = (Join-Path $stateDirectory 'last-result-2.json') } +) +function Read-Owner($definition) { + try { return [IO.File]::ReadAllText($definition.owner) } catch { return 'Owner information is being updated.' } } -function Open-Slot { - try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') } +function Open-Slot($definition) { + try { return [IO.File]::Open($definition.lock, 'OpenOrCreate', 'ReadWrite', 'None') } catch [IO.IOException] { if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw } return $null } } +function Open-AvailableSlot { + foreach ($definition in $slotDefinitions) { + $handle = Open-Slot $definition + if ($handle) { return [pscustomobject]@{ definition = $definition; handle = $handle } } + } + return $null +} if ($Status) { - $probe = Open-Slot - if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' } - else { Write-Host 'BUSY:'; Write-Host (Read-Owner) } - if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) } + $busyCount = 0 + foreach ($definition in $slotDefinitions) { + $probe = Open-Slot $definition + if ($probe) { $probe.Dispose(); Write-Host "IDLE: slot $($definition.number)/2 is free." } + else { $busyCount++; Write-Host "BUSY: slot $($definition.number)/2"; Write-Host (Read-Owner $definition) } + if (Test-Path -LiteralPath $definition.result) { + Write-Host "Last result for slot $($definition.number):" + try { Write-Host ([IO.File]::ReadAllText($definition.result)) } catch { Write-Host 'Result is being updated.' } + } + } + Write-Host "Capacity: $busyCount/2 slots occupied." exit 0 } if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' } @@ -51,20 +66,24 @@ $owner = $null $exitCode = 1 try { $nextNotice = 0 - while (-not ($slot = Open-Slot)) { + while (-not ($lease = Open-AvailableSlot)) { if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) { Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started." exit 75 } if ($timer.Elapsed.TotalSeconds -ge $nextNotice) { - Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)" + $owners = @($slotDefinitions | ForEach-Object { "slot $($_.number)/2: $(Read-Owner $_)" }) -join ' | ' + Write-Host "WAITING for either shared heavy check slot: $owners" $nextNotice = $timer.Elapsed.TotalSeconds + 15 } Start-Sleep -Milliseconds 250 } - $owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') } + $slot = $lease.handle + $ownerPath = $lease.definition.owner + $resultPath = $lease.definition.result + $owner = [ordered]@{ label = $Label; pid = $PID; slot = $lease.definition.number; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') } [IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress)) - Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)" + Write-Host "RUNNING [$Label] in $directory (shared slot $($lease.definition.number)/2; timeout ${TimeoutSeconds}s)" # Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。 if (-not ('LocalCheckJob' -as [type])) { diff --git a/scripts/local-check.test.mjs b/scripts/local-check.test.mjs index 76e7fec79..7f62c1775 100644 --- a/scripts/local-check.test.mjs +++ b/scripts/local-check.test.mjs @@ -34,7 +34,7 @@ async function until(check, message) { assert.fail(message) } -test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => { +test('local-check bounds different directories to two slots, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 120000 }, async t => { const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-')) const processes = [] const start = (...args) => { const result = run(state, ...args); processes.push(result); return result } @@ -46,19 +46,31 @@ test('local-check serializes different directories, handles failures and recover assert.match(result.output, /"2","--max-old-space-size=2048"/) assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/) }) - await t.test('a running check excludes another directory and queue timeout starts nothing', async () => { - const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)']) - await until(() => first.output().includes('HOLDER_READY'), first.output()) + await t.test('two directories run together; a third times out or waits until one lease is released', async () => { + const releaseFirst = path.join(state, 'release-first'), releaseSecond = path.join(state, 'release-second') + const hold = (file, label) => `const fs=require('fs');console.log(${JSON.stringify(label)});const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(file)}))clearInterval(timer)},50)` + const first = start([process.execPath, '-e', hold(releaseFirst, 'FIRST_READY')], { timeout: 30 }) + await until(() => first.output().includes('FIRST_READY'), first.output()) + const second = start([process.execPath, '-e', hold(releaseSecond, 'SECOND_READY')], { cwd: path.dirname(launcher), timeout: 30 }) + await until(() => second.output().includes('SECOND_READY'), second.output()) + assert.equal(first.child.exitCode, null) + const owners = await Promise.all(['owner.json', 'owner-2.json'].map(name => readFile(path.join(state, 'Chongchong', 'LocalChecks', name), 'utf8').then(JSON.parse))) + assert.deepEqual(owners.map(owner => owner.slot).sort(), [1, 2]) + assert.equal(new Set(owners.map(owner => owner.directory)).size, 2) const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done assert.equal(timedOut.code, 75, timedOut.output) assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/) - const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) }) - await until(() => second.output().includes('WAITING'), second.output()) - assert.doesNotMatch(second.output(), /SECOND_READY/) + const third = start([process.execPath, '-e', 'console.log("THIRD_READY")'], { cwd: state }) + await until(() => third.output().includes('WAITING'), third.output()) + assert.doesNotMatch(third.output(), /THIRD_READY/) + await writeFile(releaseFirst, '') assert.equal((await first.done).code, 0) - const result = await second.done + const result = await third.done assert.equal(result.code, 0, result.output) - assert.match(result.output, /SECOND_READY/) + assert.match(result.output, /THIRD_READY/) + assert.equal(second.child.exitCode, null) + await writeFile(releaseSecond, '') + assert.equal((await second.done).code, 0) }) await t.test('execution timeout kills descendants and frees the slot', async () => { const pidFile = path.join(state, 'descendant.pid')