Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc6f617f5 | ||
|
|
493656e677 | ||
|
|
880c53ab43 | ||
|
|
c53992ad59 | ||
|
|
3c57a19bab | ||
|
|
ab903fe60f | ||
|
|
f4fd56f1ce | ||
|
|
89c15a7e60 | ||
|
|
b216246335 |
@@ -1,7 +1,5 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
|
||||||
|
|
||||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||||
|
|
||||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
|
|
||||||
USE baiyue_lastleg;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
|
||||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
|
||||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
|
||||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
|
||||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
|
||||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
|
||||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
|
||||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
|
||||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
|
||||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
|
||||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
|
||||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
|
||||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
|
||||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
|
||||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
|
||||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
|
||||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
|
||||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
|
||||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
|
||||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
|
||||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
|
||||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
|
||||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
|
||||||
@@ -3203,7 +3203,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
||||||
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
||||||
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
||||||
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
|
incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
|
||||||
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
||||||
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
||||||
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
||||||
@@ -4987,19 +4987,4 @@ CREATE TABLE ll_document_sequence (
|
|||||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
|
||||||
CREATE TABLE ll_fleet_resource_photo (
|
|
||||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
|
||||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
|
||||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
|
||||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
|
||||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
|
||||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
|
||||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
|
||||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
|
||||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
|
||||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
|
||||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 235 AS initialized_tables;
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import java.util.Arrays;
|
|||||||
|
|
||||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||||
enum BusinessFileCategory {
|
enum BusinessFileCategory {
|
||||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
|
||||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
|
||||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
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_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -92,7 +90,7 @@ enum BusinessFileCategory {
|
|||||||
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
||||||
}
|
}
|
||||||
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
|
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: 车辆与司机档案照片内部文件契约
|
|
||||||
version: 1.0.0
|
|
||||||
description: >-
|
|
||||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
|
|
||||||
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
|
|
||||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
|
|
||||||
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
|
|
||||||
security:
|
|
||||||
- InternalServiceToken: []
|
|
||||||
paths:
|
|
||||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/TenantId'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessType'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessId'}
|
|
||||||
post:
|
|
||||||
summary: 上传车辆或司机档案照片
|
|
||||||
parameters:
|
|
||||||
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
multipart/form-data:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [file]
|
|
||||||
properties:
|
|
||||||
file: {type: string, format: binary}
|
|
||||||
responses:
|
|
||||||
'200': {$ref: '#/components/responses/Metadata'}
|
|
||||||
'403': {description: 内部服务令牌无效}
|
|
||||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/TenantId'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessType'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessId'}
|
|
||||||
- {$ref: '#/components/parameters/FileId'}
|
|
||||||
get:
|
|
||||||
summary: 校验归属并读取元数据
|
|
||||||
responses:
|
|
||||||
'200': {$ref: '#/components/responses/Metadata'}
|
|
||||||
'403': {description: 内部服务令牌无效}
|
|
||||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/TenantId'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessType'}
|
|
||||||
- {$ref: '#/components/parameters/BusinessId'}
|
|
||||||
- {$ref: '#/components/parameters/FileId'}
|
|
||||||
get:
|
|
||||||
summary: 校验归属并下载真实文件
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 文件内容,业务错误按JSON code识别
|
|
||||||
content:
|
|
||||||
image/jpeg: {schema: {type: string, format: binary}}
|
|
||||||
image/png: {schema: {type: string, format: binary}}
|
|
||||||
'403': {description: 内部服务令牌无效}
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
|
||||||
parameters:
|
|
||||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
|
|
||||||
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
|
|
||||||
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
|
||||||
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
|
||||||
responses:
|
|
||||||
Metadata:
|
|
||||||
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
code: {type: string}
|
|
||||||
message: {type: string, nullable: true}
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
fileId: {type: string}
|
|
||||||
tenantId: {type: string, enum: ['0']}
|
|
||||||
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
|
|
||||||
businessId: {type: string}
|
|
||||||
fileName: {type: string}
|
|
||||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
|
||||||
contentType: {type: string, enum: [image/jpeg, image/png]}
|
|
||||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.baiyue.file.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
class FleetResourcePhotoCategoryTest {
|
|
||||||
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
|
|
||||||
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
|
|
||||||
category.validateOwner(0);
|
|
||||||
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
|
|
||||||
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
|
||||||
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
|
|
||||||
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -383,12 +383,11 @@ public class FbaPricingRepository {
|
|||||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
|
||||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||||
.in(InboundPricingEntity::getProcessStatus,
|
.in(InboundPricingEntity::getProcessStatus,
|
||||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -9,12 +9,11 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ContainerCalendarSourceMapper {
|
public interface ContainerCalendarSourceMapper {
|
||||||
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
|
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
|
||||||
c.destination_warehouse_code destinationWarehouseCode,
|
c.destination_warehouse_code destinationWarehouseCode,
|
||||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||||
@@ -29,11 +28,10 @@ public interface ContainerCalendarSourceMapper {
|
|||||||
""")
|
""")
|
||||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
|
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
|
||||||
c.destination_warehouse_code destinationWarehouseCode,
|
c.destination_warehouse_code destinationWarehouseCode,
|
||||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ paths:
|
|||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
vesselName: {type: string, nullable: true}
|
vesselName: {type: string, nullable: true}
|
||||||
voyageNo: {type: string, nullable: true}
|
voyageNo: {type: string, nullable: true}
|
||||||
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
|
destinationPortName: {type: string, nullable: true}
|
||||||
destinationWarehouseCode: {type: string, nullable: true}
|
destinationWarehouseCode: {type: string, nullable: true}
|
||||||
arrivalAt: {type: string, format: date-time, nullable: true}
|
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||||
|
|||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
package com.baiyue.firstleg.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
|
||||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
|
||||||
import org.apache.ibatis.mapping.Environment;
|
|
||||||
import org.apache.ibatis.session.SqlSession;
|
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
|
||||||
import org.h2.jdbcx.JdbcDataSource;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
|
|
||||||
class FbaPricingFeeTransitionTest {
|
|
||||||
private SqlSession session;
|
|
||||||
private JdbcTemplate sql;
|
|
||||||
private FbaPricingRepository repository;
|
|
||||||
|
|
||||||
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws Exception {
|
|
||||||
var ds = new JdbcDataSource();
|
|
||||||
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
|
|
||||||
var config = new MybatisConfiguration();
|
|
||||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
|
||||||
config.addMapper(InboundPricingMapper.class);
|
|
||||||
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
|
|
||||||
session.getConnection();
|
|
||||||
sql = new JdbcTemplate(ds);
|
|
||||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
|
||||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
|
||||||
repository = new FbaPricingRepository();
|
|
||||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
if (session != null) session.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
|
||||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
|
||||||
insertInbound(processStatus, "CALCULATED");
|
|
||||||
|
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
|
||||||
assertState(processStatus, "CONFIRMED");
|
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
|
||||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
|
||||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
|
||||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
|
|
||||||
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
|
|
||||||
insertInbound("WAIT_PALLETIZE", feeStatus);
|
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
|
||||||
assertState("WAIT_PALLETIZE", feeStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
|
||||||
@Test
|
|
||||||
void preservesProcessCustomerAndDeletionGuards() {
|
|
||||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
|
||||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
|
||||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
|
||||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
|
||||||
assertState("WAIT_PALLETIZE", "CALCULATED");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
|
|
||||||
@Test
|
|
||||||
void rejectsSendWithStaleProcessStatus() {
|
|
||||||
insertInbound("WAIT_SPLIT", "CONFIRMED");
|
|
||||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
|
|
||||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
|
||||||
private void insertInbound(String processStatus, String feeStatus) {
|
|
||||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
|
||||||
processStatus, feeStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
|
|
||||||
private void assertState(String processStatus, String feeStatus) {
|
|
||||||
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
|
|
||||||
.isEqualTo(processStatus);
|
|
||||||
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
|
|
||||||
.isEqualTo(feeStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -194,9 +194,8 @@ class FbaPricingRepositoryRuleListTest {
|
|||||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
|
|
||||||
@Test
|
@Test
|
||||||
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
|
void feeConfirmationAllowsPostLoadingInboundStatuses() {
|
||||||
String source;
|
String source;
|
||||||
try {
|
try {
|
||||||
source = Files.readString(Path.of(
|
source = Files.readString(Path.of(
|
||||||
@@ -206,7 +205,7 @@ class FbaPricingRepositoryRuleListTest {
|
|||||||
throw new AssertionError(exception);
|
throw new AssertionError(exception);
|
||||||
}
|
}
|
||||||
assertThat(source)
|
assertThat(source)
|
||||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
@@ -226,13 +223,11 @@ class FbaPricingServiceTest {
|
|||||||
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待组托、待分柜确认费用均不推进物流主流程。 */
|
@Test
|
||||||
@ParameterizedTest
|
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
|
||||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||||
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
|
|
||||||
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
|
|
||||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||||
inbound.setProcessStatus(processStatus);
|
inbound.setProcessStatus("WAIT_SPLIT");
|
||||||
inbound.setFeeStatus("CALCULATED");
|
inbound.setFeeStatus("CALCULATED");
|
||||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||||
@@ -249,7 +244,7 @@ class FbaPricingServiceTest {
|
|||||||
FeeSnapshotResponse response = service.confirm(
|
FeeSnapshotResponse response = service.confirm(
|
||||||
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
||||||
|
|
||||||
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
|
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,13 +436,11 @@ class FbaPricingServiceTest {
|
|||||||
verify(repository, never()).insertFee(any());
|
verify(repository, never()).insertFee(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
@Test
|
||||||
@ParameterizedTest
|
|
||||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
|
||||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||||
InboundPricingEntity inbound = inbound("10", "1");
|
InboundPricingEntity inbound = inbound("10", "1");
|
||||||
inbound.setProcessStatus(processStatus);
|
inbound.setProcessStatus("FEE_CONFIRMING");
|
||||||
inbound.setFeeStatus("CONFIRMED");
|
inbound.setFeeStatus("CONFIRMED");
|
||||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
@@ -455,7 +448,7 @@ class FbaPricingServiceTest {
|
|||||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||||
when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
|
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1);
|
||||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||||
@@ -466,8 +459,8 @@ class FbaPricingServiceTest {
|
|||||||
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||||
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||||
|
|
||||||
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
|
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||||
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
|
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||||
verify(financeOutbox, times(1)).publishInbound(
|
verify(financeOutbox, times(1)).publishInbound(
|
||||||
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
|
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
|
||||||
eq(501L), eq(1), eq(NOW));
|
eq(501L), eq(1), eq(NOW));
|
||||||
|
|||||||
+4
-20
@@ -36,9 +36,8 @@ class MerchantInboundBillingServiceTest {
|
|||||||
@MockBean private InboundFeeDetailService feeDetails;
|
@MockBean private InboundFeeDetailService feeDetails;
|
||||||
@Resource private MerchantInboundBillingService service;
|
@Resource private MerchantInboundBillingService service;
|
||||||
|
|
||||||
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||||
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
|
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
|
||||||
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
|
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
|
||||||
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
|
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
|
||||||
@@ -85,11 +84,9 @@ class MerchantInboundBillingServiceTest {
|
|||||||
verify(orders).findByOrderId(10001L, 77L);
|
verify(orders).findByOrderId(10001L, 77L);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待组托不绕过客户费用可见性的测量审核条件。 */
|
@Test
|
||||||
@ParameterizedTest
|
void shouldHandleCase02() {
|
||||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT");
|
||||||
void shouldHandleCase02(String processStatus) {
|
|
||||||
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
|
|
||||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.find(10001L, 77L))
|
assertThatThrownBy(() -> service.find(10001L, 77L))
|
||||||
@@ -108,19 +105,6 @@ class MerchantInboundBillingServiceTest {
|
|||||||
verify(pricing, never()).latest(20002L, 88L);
|
verify(pricing, never()).latest(20002L, 88L);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
|
|
||||||
@Test
|
|
||||||
void confirmsPublishedFeeWhileWaitingForPalletization() {
|
|
||||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
|
|
||||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
|
||||||
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
|
|
||||||
.thenReturn(fee);
|
|
||||||
|
|
||||||
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
|
|
||||||
.isSameAs(fee);
|
|
||||||
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldHandleCase04() {
|
void shouldHandleCase04() {
|
||||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
|
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
|
||||||
|
|||||||
+2
-37
@@ -11,41 +11,6 @@ import java.time.LocalDateTime;
|
|||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class ContainerCalendarSourceMapperTest {
|
class ContainerCalendarSourceMapperTest {
|
||||||
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
|
|
||||||
@Test
|
|
||||||
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
|
|
||||||
var ds = new JdbcDataSource();
|
|
||||||
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
|
||||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
|
||||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
|
||||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
|
||||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
|
||||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
|
||||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
|
||||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
|
||||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
|
||||||
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
|
|
||||||
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
|
|
||||||
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
|
|
||||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
|
||||||
}
|
|
||||||
var config = new MybatisConfiguration();
|
|
||||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
|
||||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
|
||||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
|
||||||
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
|
|
||||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
|
||||||
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
|
||||||
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
|
|
||||||
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
|
|
||||||
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
|
|
||||||
for (var row : explicit) {
|
|
||||||
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
|
|
||||||
}
|
|
||||||
assertEquals(5, explicit.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||||
@Test
|
@Test
|
||||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||||
@@ -53,7 +18,7 @@ class ContainerCalendarSourceMapperTest {
|
|||||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||||
@@ -100,7 +65,7 @@ class ContainerCalendarSourceMapperTest {
|
|||||||
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
|
||||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||||
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||||
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
||||||
|
|
||||||
@@ -162,5 +161,3 @@
|
|||||||
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
|
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
|
||||||
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
|
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
|
||||||
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
|
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
|
||||||
|
|
||||||
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
|
|
||||||
|
|||||||
@@ -264,7 +264,3 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
|
|||||||
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
|
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
|
||||||
|
|
||||||
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
|
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
|
||||||
|
|
||||||
### 档案照片只读复用边界(2026-10-26)
|
|
||||||
|
|
||||||
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
|
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
|||||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||||
assert.ok(dispatchEdges.length > 0);
|
assert.ok(dispatchEdges.length > 0);
|
||||||
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|
||||||
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
|
|
||||||
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|
|
||||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
|
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
|
||||||
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|
||||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
||||||
@@ -84,7 +82,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
|||||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||||
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
||||||
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
||||||
'TransportTaskService->photo.FleetResourcePhotoModels',
|
|
||||||
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
|
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
|
||||||
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
|
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
|
||||||
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
|
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
|
|||||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||||
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||||
WHERE id=#{r.id} AND record_version=#{version}
|
WHERE id=#{r.id} AND record_version=#{version}
|
||||||
""")
|
""")
|
||||||
|
|||||||
+2
-4
@@ -11,8 +11,6 @@ import java.util.List;
|
|||||||
public interface DispatchCandidateMapper {
|
public interface DispatchCandidateMapper {
|
||||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||||
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
|
|
||||||
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
|
|
||||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||||
String DEPARTED_PLAN =
|
String DEPARTED_PLAN =
|
||||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||||
@@ -180,7 +178,7 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||||
@@ -285,7 +283,7 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
|
|||||||
+2
-19
@@ -33,7 +33,7 @@ public interface DispatchSourceMapper {
|
|||||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||||
@@ -41,23 +41,6 @@ public interface DispatchSourceMapper {
|
|||||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||||
|
|
||||||
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
|
|
||||||
@Select("""
|
|
||||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
|
|
||||||
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
|
||||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
|
|
||||||
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
|
|
||||||
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
|
|
||||||
AND COALESCE(p.actual_package_count,0)=0
|
|
||||||
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
|
|
||||||
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
|
|
||||||
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
|
||||||
""")
|
|
||||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
|
||||||
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
|
|
||||||
|
|
||||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||||
@@ -132,7 +115,7 @@ public interface DispatchSourceMapper {
|
|||||||
|
|
||||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
|
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||||
|
|
||||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||||
|
|||||||
-2
@@ -68,8 +68,6 @@ public class DispatchSourceRepository {
|
|||||||
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
||||||
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
||||||
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
||||||
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
|
|
||||||
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
|
|
||||||
/** 取得已有真实退回候选。 */
|
/** 取得已有真实退回候选。 */
|
||||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||||
/** 同托真实来源订单组成。 */
|
/** 同托真实来源订单组成。 */
|
||||||
|
|||||||
+5
-26
@@ -33,7 +33,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|||||||
@Service
|
@Service
|
||||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||||
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
|
|
||||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||||
@@ -311,7 +310,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
||||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||||
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||||
Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources));
|
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
|
||||||
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
||||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
||||||
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
||||||
@@ -444,7 +443,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
|
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
|
||||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
|
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
|
||||||
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
|
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
|
||||||
var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance()));
|
var savedResources=resources(actual,photos,request.maintenance());
|
||||||
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
|
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
|
||||||
var savedDriver=savedResources.driver();
|
var savedDriver=savedResources.driver();
|
||||||
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
|
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
|
||||||
@@ -734,25 +733,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||||
long version=row.getRecordVersion();
|
long version=row.getRecordVersion();
|
||||||
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
|
|
||||||
var all=repository.cargo(row.getId());
|
|
||||||
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
|
|
||||||
int previousPackages=row.getPackageCount();
|
|
||||||
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
|
||||||
row.setItemCount(all.size());
|
|
||||||
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
|
||||||
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
|
|
||||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
|
||||||
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
|
|
||||||
if(removedItems || previousPackages!=row.getPackageCount()) {
|
|
||||||
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
|
||||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
|
||||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
|
||||||
}
|
|
||||||
if(all.isEmpty()) {
|
|
||||||
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
|
|
||||||
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
|
|
||||||
}
|
|
||||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||||
@@ -795,22 +775,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||||
}
|
}
|
||||||
/** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */
|
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */
|
||||||
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
|
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
|
||||||
var driver=resources.driver();var proof=driver.evidence();
|
var driver=resources.driver();var proof=driver.evidence();
|
||||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
|
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
|
||||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
|
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
|
||||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
|
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
|
||||||
}
|
}
|
||||||
/** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */
|
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */
|
||||||
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
|
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
|
||||||
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
|
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
|
||||||
boolean image=false;
|
boolean image=false;
|
||||||
for(var id:ids) {
|
for(var id:ids) {
|
||||||
var master=masterEvidenceDriverId(row,kind,id);
|
var master=masterEvidenceDriverId(row,kind,id);
|
||||||
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
|
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
|
||||||
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())
|
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true;
|
||||||
|| kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true;
|
|
||||||
}
|
}
|
||||||
if(!image)throw bad("请上传至少一张有效"+label);
|
if(!image)throw bad("请上传至少一张有效"+label);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-22
@@ -67,13 +67,7 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
|||||||
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
||||||
boolean preserveReview=workflow.currentReceiptBasis(row);
|
boolean preserveReview=workflow.currentReceiptBasis(row);
|
||||||
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
||||||
var empty=new ArrayList<DispatchItem>();
|
for(var item:pending) {
|
||||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
|
||||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
|
||||||
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
|
|
||||||
empty.add(item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||||
received.put(item,actual);
|
received.put(item,actual);
|
||||||
@@ -85,25 +79,14 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
|||||||
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
||||||
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
||||||
}
|
}
|
||||||
for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||||
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
||||||
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||||
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
||||||
}
|
}
|
||||||
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
|
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||||
if(!empty.isEmpty()) {
|
|
||||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
|
|
||||||
var original=physicalClaims.lockOwner(owner);
|
|
||||||
for(var item:empty) {
|
|
||||||
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
|
|
||||||
throw conflict("空托盘已有真实拣货,不能按短少移除");
|
|
||||||
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
|
|
||||||
}
|
|
||||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
|
||||||
}
|
|
||||||
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
|
||||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
||||||
for(var entry:received.entrySet()) {
|
for(var entry:received.entrySet()) {
|
||||||
var item=entry.getKey();var actual=entry.getValue();
|
var item=entry.getKey();var actual=entry.getValue();
|
||||||
@@ -112,8 +95,16 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
|||||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||||
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
||||||
}
|
}
|
||||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
|
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
|
||||||
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
|
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
|
||||||
|
if(total!=row.getPackageCount()) {
|
||||||
|
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||||
|
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||||
|
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||||
|
fleet.updateDispatchCargo(number,total,stops);
|
||||||
|
}
|
||||||
|
row.setPackageCount(total);
|
||||||
|
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
package com.baiyue.lastleg.dispatch.service;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
|
||||||
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind;
|
|
||||||
import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.util.List;
|
|
||||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|
||||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
|
|
||||||
|
|
||||||
/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */
|
|
||||||
@Service
|
|
||||||
public class DispatchMasterPhotoService {
|
|
||||||
private final FleetResourcePhotoPort photos;
|
|
||||||
private final DispatchEvidenceGateway evidence;
|
|
||||||
/** 只读主档并通过本车文件网关创建证据。 */
|
|
||||||
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
|
|
||||||
this.photos=photos; this.evidence=evidence;
|
|
||||||
}
|
|
||||||
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
|
|
||||||
public Resources populate(long dispatchId,String operator,Resources value) {
|
|
||||||
var v=value.vehicle(); var d=value.driver();
|
|
||||||
if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) {
|
|
||||||
var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds());
|
|
||||||
v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids);
|
|
||||||
}
|
|
||||||
if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) {
|
|
||||||
var e=d.evidence();
|
|
||||||
var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds());
|
|
||||||
d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId()));
|
|
||||||
}
|
|
||||||
return new Resources(v,d,value.maintenance());
|
|
||||||
}
|
|
||||||
/** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */
|
|
||||||
public List<Long> populate(long dispatchId,String operator,Kind kind,long resourceId,List<Long> existing) {
|
|
||||||
if(existing!=null && !existing.isEmpty())return List.copyOf(existing);
|
|
||||||
var current=photos.current(kind,resourceId);
|
|
||||||
if(current.photo()==null)return List.of();
|
|
||||||
var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId()));
|
|
||||||
var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content());
|
|
||||||
var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file);
|
|
||||||
return List.of(copied.fileId());
|
|
||||||
}
|
|
||||||
/** 只供服务间复制已核验图片,不接受任意磁盘路径。 */
|
|
||||||
private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile {
|
|
||||||
/** 上传部件固定使用文件字段。 */
|
|
||||||
@Override public String getName() { return "file"; }
|
|
||||||
/** 保留已核验主档文件名。 */
|
|
||||||
@Override public String getOriginalFilename() { return filename; }
|
|
||||||
/** 沿用已核验图片类型。 */
|
|
||||||
@Override public String getContentType() { return contentType; }
|
|
||||||
/** 拒绝空图片。 */
|
|
||||||
@Override public boolean isEmpty() { return bytes.length==0; }
|
|
||||||
/** 使用实际读取的字节长度。 */
|
|
||||||
@Override public long getSize() { return bytes.length; }
|
|
||||||
/** 返回本次复制的图片内容。 */
|
|
||||||
@Override public byte[] getBytes() { return bytes; }
|
|
||||||
/** 提供上传流,不读取磁盘文件。 */
|
|
||||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
|
||||||
/** 实现文件上传协议要求的输出方法。 */
|
|
||||||
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -36,7 +36,6 @@ public class TransportTaskService {
|
|||||||
private final TripOperationsEvidenceGateway files;
|
private final TripOperationsEvidenceGateway files;
|
||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
|
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos;
|
|
||||||
/** 复用已有资格、占用、原单及凭证能力。 */
|
/** 复用已有资格、占用、原单及凭证能力。 */
|
||||||
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
||||||
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
||||||
@@ -120,20 +119,6 @@ public class TransportTaskService {
|
|||||||
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|
||||||
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
|
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
|
||||||
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
|
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
|
||||||
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
|
|
||||||
var supplied=request.reviewPhotos();
|
|
||||||
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
|
|
||||||
request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds());
|
|
||||||
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
|
|
||||||
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
|
|
||||||
var license=request.masterLicenseFileId();
|
|
||||||
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
|
|
||||||
var driver=drivers.detail(request.driverId(),null);
|
|
||||||
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
|
|
||||||
}
|
|
||||||
request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(),
|
|
||||||
request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments(
|
|
||||||
vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license);
|
|
||||||
photos=reviewPhotos.validate(row,trip,request);
|
photos=reviewPhotos.validate(row,trip,request);
|
||||||
}
|
}
|
||||||
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
|
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
|
||||||
|
|||||||
+1
-4
@@ -27,10 +27,7 @@ public final class OutsourcedBusinessModels {
|
|||||||
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||||
}
|
}
|
||||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
|
||||||
/** 未指定替换目标时追加装车照片。 */
|
|
||||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
|
||||||
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ public class OutsourcedBusinessService {
|
|||||||
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||||
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||||
if(request==null)throw bad("照片绑定请求缺失");
|
if(request==null)throw bad("照片绑定请求缺失");
|
||||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE));
|
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||||
}
|
}
|
||||||
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||||
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||||
|
|||||||
+1
-5
@@ -34,11 +34,7 @@ public final class OwnedBusinessModels {
|
|||||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
|
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
|
||||||
@Size(max=1000) String reason) {}
|
@Size(max=1000) String reason) {}
|
||||||
public record BindPhoto(@NotBlank String recordVersion,
|
public record BindPhoto(@NotBlank String recordVersion,
|
||||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
|
||||||
/** 未指定替换目标时追加装车照片。 */
|
|
||||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
|
||||||
}
|
|
||||||
public record Gate(boolean allowed,String blockReason) {}
|
public record Gate(boolean allowed,String blockReason) {}
|
||||||
public record Actions(Gate edit,Gate upload) {}
|
public record Actions(Gate edit,Gate upload) {}
|
||||||
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
|
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
|
||||||
|
|||||||
+1
-5
@@ -8,11 +8,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
public interface DispatchLoadingPhotoPort {
|
public interface DispatchLoadingPhotoPort {
|
||||||
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
|
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
|
||||||
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||||
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
|
||||||
/** 未指定替换目标时追加照片,每类最多五张。 */
|
|
||||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
|
||||||
}
|
|
||||||
record State(String recordVersion,List<Photo> photos) {}
|
record State(String recordVersion,List<Photo> photos) {}
|
||||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||||
record Download(Photo photo,byte[] content) {}
|
record Download(Photo photo,byte[] content) {}
|
||||||
|
|||||||
+3
-4
@@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
|||||||
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||||
var row=repository.lock(seed);
|
var row=repository.lock(seed);
|
||||||
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
||||||
var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
|
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||||
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
|
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||||
row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
||||||
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
|
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
|
||||||
@@ -104,7 +104,6 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
|||||||
private void validate(Slot slot,BindPhoto request) {
|
private void validate(Slot slot,BindPhoto request) {
|
||||||
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
|
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
|
||||||
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
|
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
|
||||||
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
|
|
||||||
}
|
}
|
||||||
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
|
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
|
||||||
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
|
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
|
||||||
|
|||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
|
||||||
|
|
||||||
/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */
|
|
||||||
final class OwnedBusinessPhotoBindings {
|
|
||||||
/** 工具类仅提供照片引用合并。 */
|
|
||||||
private OwnedBusinessPhotoBindings() {}
|
|
||||||
/** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */
|
|
||||||
static List<Photo> bind(List<Photo> existing,Photo photo,String replaceFileId) {
|
|
||||||
var result=new ArrayList<>(existing);
|
|
||||||
// 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。
|
|
||||||
if(replaceFileId==null && "FUEL".equals(photo.category()))
|
|
||||||
replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null);
|
|
||||||
if(replaceFileId!=null) {
|
|
||||||
int index=-1;
|
|
||||||
for(int i=0;i<result.size();i++)if(result.get(i).slot()==photo.slot() && result.get(i).fileId().equals(replaceFileId))index=i;
|
|
||||||
if(index<0)throw conflict("待替换照片已变化,请刷新后核对");
|
|
||||||
result.remove(index);
|
|
||||||
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
|
|
||||||
result.add(index,photo);
|
|
||||||
} else {
|
|
||||||
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
|
|
||||||
int limit="LOADING".equals(photo.category())?5:1;
|
|
||||||
if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片");
|
|
||||||
result.add(photo);
|
|
||||||
}
|
|
||||||
return List.copyOf(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-9
@@ -69,20 +69,20 @@ public class OwnedBusinessRecordService {
|
|||||||
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
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);}
|
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||||
requireVersion(row,version);
|
requireVersion(row,version);
|
||||||
var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||||
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
|
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||||
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
|
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||||
row.setPhotosJson(codec.write(photos));
|
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
|
||||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||||
}
|
}
|
||||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||||
private String photoReason(PhotoSlot slot) {
|
private String photoReason(PhotoSlot slot) {
|
||||||
return switch(slot) {
|
return switch(slot) {
|
||||||
case LOADING_SCENE -> "保存司机驾驶证照片";
|
case LOADING_SCENE -> "保存装车现场照片";
|
||||||
case LOADING_LIST -> "保存车牌照片";
|
case LOADING_LIST -> "保存装车清单照片";
|
||||||
case PRE_DEPARTURE_CHECK -> "保存装车完成照片";
|
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
|
||||||
case DRIVER_LICENSE -> "保存封车照片";
|
case DRIVER_LICENSE -> "保存驾驶证附件照片";
|
||||||
case RECEIPT -> "保存BOL司机签字照片";
|
case RECEIPT -> "保存签收凭证照片";
|
||||||
case FUEL_START -> "保存起始油耗表盘照片";
|
case FUEL_START -> "保存起始油耗表盘照片";
|
||||||
case FUEL_END -> "保存结束油耗表盘照片";
|
case FUEL_END -> "保存结束油耗表盘照片";
|
||||||
};
|
};
|
||||||
|
|||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
/** File按平台、档案类型、资源身份及文件身份隔离照片。 */
|
|
||||||
public interface FleetResourcePhotoGateway {
|
|
||||||
/** 上传平台所属的档案照片。 */
|
|
||||||
Photo upload(Kind kind, long id, String operator, MultipartFile file);
|
|
||||||
/** 核验档案类型、资源身份和文件身份。 */
|
|
||||||
Photo metadata(Kind kind, long id, long file);
|
|
||||||
/** 读取已核验归属的图片内容。 */
|
|
||||||
byte[] download(Kind kind, long id, long file);
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.*;
|
|
||||||
|
|
||||||
/** 仅操作档案照片版本表;资源有效性通过所属领域服务核验。 */
|
|
||||||
@Mapper
|
|
||||||
public interface FleetResourcePhotoMapper {
|
|
||||||
/** 读取同一档案的最大照片版本。 */
|
|
||||||
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} ORDER BY record_version DESC LIMIT 1")
|
|
||||||
FleetResourcePhotoModels.Stored current(@Param("kind") String kind, @Param("id") long id);
|
|
||||||
/** 按档案与文件复合身份查询历史引用。 */
|
|
||||||
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} AND file_id=#{file} LIMIT 1")
|
|
||||||
FleetResourcePhotoModels.Stored file(@Param("kind") String kind, @Param("id") long id, @Param("file") long file);
|
|
||||||
/** 追加不可变照片版本,数据库拒绝重复资源版本。 */
|
|
||||||
@Insert("INSERT INTO ll_fleet_resource_photo(resource_kind,resource_id,record_version,file_id,file_name,content_type,size_bytes,created_by,created_at) VALUES(#{resourceKind},#{resourceId},#{recordVersion},#{fileId},#{fileName},#{contentType},#{sizeBytes},#{createdBy},#{createdAt})")
|
|
||||||
int insert(FleetResourcePhotoModels.Stored row);
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/** 档案照片独立版本化,历史图片不覆盖且不改变驾驶证审核结论。 */
|
|
||||||
public final class FleetResourcePhotoModels {
|
|
||||||
/** 仅承载照片接口与持久化数据。 */
|
|
||||||
private FleetResourcePhotoModels() { }
|
|
||||||
public enum Kind { VEHICLE, DRIVER }
|
|
||||||
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
|
|
||||||
public record Detail(long recordVersion, Photo photo) { }
|
|
||||||
public record Stored(String resourceKind, long resourceId, long recordVersion, long fileId,
|
|
||||||
String fileName, String contentType, long sizeBytes, String createdBy, LocalDateTime createdAt) { }
|
|
||||||
public record Download(Photo metadata, byte[] content) { }
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
/** 同一资源版本唯一,阻止并发替换静默覆盖。 */
|
|
||||||
@Repository
|
|
||||||
public class FleetResourcePhotoRepository {
|
|
||||||
private final FleetResourcePhotoMapper mapper;
|
|
||||||
/** 注入唯一照片Mapper。 */
|
|
||||||
public FleetResourcePhotoRepository(FleetResourcePhotoMapper mapper) { this.mapper = mapper; }
|
|
||||||
/** 读取指定档案的最新照片版本。 */
|
|
||||||
public Stored current(Kind kind, long id) { return mapper.current(kind.name(), id); }
|
|
||||||
/** 按档案及文件身份读取不可变历史引用。 */
|
|
||||||
public Stored file(Kind kind, long id, long file) { return mapper.file(kind.name(), id, file); }
|
|
||||||
/** 追加新版本,唯一键负责并发冲突保护。 */
|
|
||||||
public void append(Stored row) { if (mapper.insert(row) != 1) throw new IllegalStateException("档案照片未保存"); }
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
|
||||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
/** 管理可重复用于排车的车辆及司机照片,排车另存当趟不可变副本。 */
|
|
||||||
@Service
|
|
||||||
public class FleetResourcePhotoService implements com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort {
|
|
||||||
private final FleetResourcePhotoRepository repository;
|
|
||||||
private final FleetResourcePhotoGateway files;
|
|
||||||
private final VehicleRepository vehicles;
|
|
||||||
private final DriverService drivers;
|
|
||||||
/** 通过资源领域入口确认身份,所有照片版本经Repository保存。 */
|
|
||||||
public FleetResourcePhotoService(FleetResourcePhotoRepository repository, FleetResourcePhotoGateway files,
|
|
||||||
VehicleRepository vehicles, DriverService drivers) {
|
|
||||||
this.repository=repository; this.files=files; this.vehicles=vehicles; this.drivers=drivers;
|
|
||||||
}
|
|
||||||
/** 先确认资源存在,空照片是明确的可补齐状态。 */
|
|
||||||
public Detail current(Kind kind, long id) {
|
|
||||||
requireResource(kind,id);
|
|
||||||
var row=repository.current(kind,id);
|
|
||||||
return row==null ? new Detail(0,null) : new Detail(row.recordVersion(),photo(row));
|
|
||||||
}
|
|
||||||
/** 版本随照片独立递增;失败保留旧照片及历史。 */
|
|
||||||
@Transactional
|
|
||||||
public Detail upload(Kind kind,long id,long version,String operator,MultipartFile file) {
|
|
||||||
var current=current(kind,id);
|
|
||||||
if(version!=current.recordVersion())throw conflict();
|
|
||||||
var saved=files.upload(kind,id,operator,file);
|
|
||||||
try {
|
|
||||||
repository.append(new Stored(kind.name(),id,version+1,Long.parseLong(saved.fileId()),saved.fileName(),
|
|
||||||
saved.contentType(),saved.sizeBytes(),operator,LocalDateTime.now(ZoneOffset.UTC)));
|
|
||||||
} catch(DuplicateKeyException error) { throw conflict(); }
|
|
||||||
return new Detail(version+1,saved);
|
|
||||||
}
|
|
||||||
/** 只允许读取本档案已关联的当前或历史图片。 */
|
|
||||||
public Download download(Kind kind,long id,long file) {
|
|
||||||
requireResource(kind,id);
|
|
||||||
if(repository.file(kind,id,file)==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"档案照片不存在");
|
|
||||||
var metadata=files.metadata(kind,id,file);
|
|
||||||
var bytes=files.download(kind,id,file);
|
|
||||||
if(bytes.length!=metadata.sizeBytes())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"档案照片内容不完整");
|
|
||||||
return new Download(metadata,bytes);
|
|
||||||
}
|
|
||||||
/** 车辆及司机身份必须真实存在,不能按自由文本上传。 */
|
|
||||||
private void requireResource(Kind kind,long id) {
|
|
||||||
if(id<=0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"档案身份无效");
|
|
||||||
if(kind==Kind.VEHICLE)vehicles.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"车辆档案不存在")); else drivers.detail(id,null);
|
|
||||||
}
|
|
||||||
/** 转换为不暴露持久化信息的文件元数据。 */
|
|
||||||
private static Photo photo(Stored row) { return new Photo(Long.toString(row.fileId()),row.fileName(),row.contentType(),row.sizeBytes()); }
|
|
||||||
/** 冲突要求重读当前照片,不自动覆盖他人的修改。 */
|
|
||||||
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT,"档案照片已变化,请重新读取后上传"); }
|
|
||||||
}
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
|
||||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
|
||||||
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 org.springframework.web.multipart.MultipartFile;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class HttpFleetResourcePhotoGateway implements FleetResourcePhotoGateway {
|
|
||||||
private static final String PATH = "/internal/file/v1/business-files/0/{type}/{record}";
|
|
||||||
private static final long MAX_SIZE = 10L * 1024 * 1024;
|
|
||||||
private final RestClient client;
|
|
||||||
private final String token;
|
|
||||||
/** 使用统一内部服务客户端与File通信。 */
|
|
||||||
public HttpFleetResourcePhotoGateway(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;
|
|
||||||
}
|
|
||||||
/** 只接受非空图片,通过内部File上传并校验归属。 */
|
|
||||||
@Override public Photo upload(Kind kind, long recordId, String operatorId, MultipartFile file) {
|
|
||||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("档案照片须为非空 JPG 或 PNG,最大 10MiB");
|
|
||||||
var body = new MultipartBodyBuilder();
|
|
||||||
try { body.part("file", new ByteArrayResource(file.getBytes()) {
|
|
||||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
|
||||||
}).contentType(MediaType.parseMediaType(file.getContentType())); }
|
|
||||||
catch (IOException error) { throw bad("无法读取档案照片"); }
|
|
||||||
var result = client.post().uri(PATH, type(kind), recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
|
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
|
||||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
|
|
||||||
return verified(result, kind, recordId, null);
|
|
||||||
}
|
|
||||||
/** 逐文件核验平台及当前档案复合归属。 */
|
|
||||||
@Override public Photo metadata(Kind kind, long recordId, long fileId) {
|
|
||||||
return verified(client.get().uri(PATH + "/{file}", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
|
|
||||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), kind, recordId, fileId);
|
|
||||||
}
|
|
||||||
/** 按原档案身份读取图片,不返回匿名URL。 */
|
|
||||||
@Override public byte[] download(Kind kind, long recordId, long fileId) {
|
|
||||||
var response = client.get().uri(PATH + "/{file}/content", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
|
|
||||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
|
|
||||||
return imageContent(response);
|
|
||||||
}
|
|
||||||
/** 拒绝错误正文、空文件和超过上限的内容。 */
|
|
||||||
static byte[] imageContent(ResponseEntity<byte[]> response) {
|
|
||||||
byte[] content = response.getBody();
|
|
||||||
if (content == null || content.length == 0 || content.length > MAX_SIZE
|
|
||||||
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
/** 验证File返回的身份、类型及字节数。 */
|
|
||||||
static Photo verified(Envelope result, Kind kind, long recordId, Long fileId) {
|
|
||||||
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
var data = result.data();
|
|
||||||
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|
|
||||||
|| !type(kind).equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|
|
||||||
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|
|
||||||
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
|
|
||||||
throw bad("文件不属于当前资源档案或不是有效照片");
|
|
||||||
return new Photo(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
|
||||||
}
|
|
||||||
/** 档案类型不可由调用方拼接任意文件业务类别。 */
|
|
||||||
private static String type(Kind kind) { return kind == Kind.VEHICLE ? "FLEET_VEHICLE_PHOTO" : "FLEET_DRIVER_PHOTO"; }
|
|
||||||
/** 车辆和司机本人照片仅允许JPG及PNG。 */
|
|
||||||
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
|
|
||||||
/** 返回可修正的附件业务错误。 */
|
|
||||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
|
||||||
/** 外部失败保留诊断,不伪装为成功。 */
|
|
||||||
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "档案照片不可用或归属校验失败"); }
|
|
||||||
record Envelope(String code, Metadata data) { }
|
|
||||||
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
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;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
/** 复用车辆编辑、司机上传及各自列表权限,不扩大跨档案访问域。 */
|
|
||||||
@RestController
|
|
||||||
@Validated
|
|
||||||
@RequestMapping("/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}")
|
|
||||||
public class PlatformFleetResourcePhotoController {
|
|
||||||
private final FleetResourcePhotoService service;
|
|
||||||
/** 注入档案照片应用服务。 */
|
|
||||||
public PlatformFleetResourcePhotoController(FleetResourcePhotoService service) { this.service=service; }
|
|
||||||
/** 列表权限读取档案照片及版本。 */
|
|
||||||
@GetMapping
|
|
||||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
|
|
||||||
public ApiResponse<Detail> current(@PathVariable Kind kind,@PathVariable @Min(1) long id) {
|
|
||||||
return ApiResponse.success(service.current(kind,id));
|
|
||||||
}
|
|
||||||
/** 按资源类型校验上传权限并提交当前版本。 */
|
|
||||||
@PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
||||||
@PreAuthorize("(#kind.name() == 'VEHICLE' and hasAuthority('platform:overseas-master-data:vehicle:edit')) or (#kind.name() == 'DRIVER' and hasAuthority('platform:overseas-master-data:driver:upload'))")
|
|
||||||
public ApiResponse<Detail> upload(@PathVariable Kind kind,@PathVariable @Min(1) long id,
|
|
||||||
@RequestParam @Min(0) long recordVersion,@RequestPart("file") MultipartFile file,
|
|
||||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
|
||||||
return ApiResponse.success(service.upload(kind,id,recordVersion,user.userId().toString(),file));
|
|
||||||
}
|
|
||||||
/** 原档案读取权限包含其已关联的历史图片。 */
|
|
||||||
@GetMapping("/{fileId}/content")
|
|
||||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
|
|
||||||
public ResponseEntity<byte[]> download(@PathVariable Kind kind,@PathVariable @Min(1) long id,@PathVariable @Min(1) long fileId) {
|
|
||||||
var file=service.download(kind,id,fileId);
|
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.metadata().fileName(),StandardCharsets.UTF_8).build().toString())
|
|
||||||
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo.port;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
|
|
||||||
/** 排车仅通过此只读应用端口获取档案照片,不访问照片持久化或改写档案。 */
|
|
||||||
public interface FleetResourcePhotoPort {
|
|
||||||
/** 返回当前档案照片及其独立版本,缺失时photo为空。 */
|
|
||||||
Detail current(Kind kind,long id);
|
|
||||||
/** 读取已经关联本档案的图片并复验平台文件归属。 */
|
|
||||||
Download download(Kind kind,long id,long file);
|
|
||||||
}
|
|
||||||
+1
-2
@@ -17,13 +17,12 @@ public class TripOperationsRepository {
|
|||||||
ORDER BY h.record_version LIMIT 1) AS snapshot_json,
|
ORDER BY h.record_version LIMIT 1) AS snapshot_json,
|
||||||
(SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no
|
(SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no
|
||||||
ORDER BY r.record_version DESC LIMIT 1) AS measured_json
|
ORDER BY r.record_version DESC LIMIT 1) AS measured_json
|
||||||
FROM ll_fleet_trip t WHERE t.trip_purpose='DELIVERY' AND t.departed_at >= ? AND t.departed_at < ?
|
FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ?
|
||||||
""");
|
""");
|
||||||
var args = new ArrayList<Object>();
|
var args = new ArrayList<Object>();
|
||||||
args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC));
|
args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC));
|
||||||
if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); }
|
if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); }
|
||||||
if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); }
|
if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); }
|
||||||
// 派送统计只消费 DEPART 实载快照;取货与本土卡车使用各自的执行历史,不能混入此投影。
|
|
||||||
// Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot.
|
// Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot.
|
||||||
sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001");
|
sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001");
|
||||||
return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"),
|
return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"),
|
||||||
|
|||||||
-17
@@ -38,23 +38,6 @@ public class InboundPlanController {
|
|||||||
@Resource private InboundPlanService service;
|
@Resource private InboundPlanService service;
|
||||||
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
||||||
@Resource private InboundFbaAppointmentService appointmentService;
|
@Resource private InboundFbaAppointmentService appointmentService;
|
||||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
|
||||||
|
|
||||||
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */
|
|
||||||
@PostMapping("/{planNo}/confirm-shortage")
|
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
|
||||||
@Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
|
||||||
public ApiResponse<com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses.Task> confirmShortage(
|
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
|
||||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
|
||||||
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
|
||||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少");
|
|
||||||
}
|
|
||||||
String customerId = convertScope(user, request.customerId());
|
|
||||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
|
||||||
return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按入仓计划明细分页查询可预约的入库订单行。 */
|
/** 按入仓计划明细分页查询可预约的入库订单行。 */
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */
|
|
||||||
public record ConfirmInboundShortageRequest(
|
|
||||||
@NotBlank String customerId,
|
|
||||||
@NotNull @Min(0) Integer version,
|
|
||||||
@NotNull @Min(1) Integer expectedPackageCount,
|
|
||||||
@NotNull @Min(1) Integer receivedPackageCount,
|
|
||||||
@NotBlank @Size(max = 200) String reason
|
|
||||||
) { }
|
|
||||||
+1
-1
@@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway {
|
|||||||
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
||||||
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
||||||
|
|
||||||
/** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */
|
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
|
||||||
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
||||||
|
|
||||||
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
||||||
|
|||||||
-26
@@ -10,32 +10,6 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
|
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
|
||||||
record FrozenSku(String skuCode, long packages) { }
|
|
||||||
|
|
||||||
/** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */
|
|
||||||
@Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages "
|
|
||||||
+ "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id "
|
|
||||||
+ "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))")
|
|
||||||
List<FrozenSku> frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
|
||||||
|
|
||||||
/** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */
|
|
||||||
@Select("""
|
|
||||||
SELECT COUNT(*) FROM ll_inbound_plan_line selected
|
|
||||||
JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id
|
|
||||||
JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id
|
|
||||||
JOIN ll_inbound_plan p ON p.container_no=sp.container_no
|
|
||||||
JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id
|
|
||||||
JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
|
|
||||||
WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId}
|
|
||||||
AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL
|
|
||||||
AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id)
|
|
||||||
AND TRIM(o.container_no)=sp.container_no
|
|
||||||
AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP'
|
|
||||||
AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code))
|
|
||||||
AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA'
|
|
||||||
""")
|
|
||||||
long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
|
||||||
|
|
||||||
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
|
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
|
||||||
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
|
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
|
||||||
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
|
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
|
||||||
|
|||||||
-16
@@ -36,22 +36,6 @@ public class PdaWorkflowRepository {
|
|||||||
@Resource private InboundPalletMergeMapper merges;
|
@Resource private InboundPalletMergeMapper merges;
|
||||||
@Resource private InboundPlanCartonMapper cartons;
|
@Resource private InboundPlanCartonMapper cartons;
|
||||||
@Resource private LastlegOrderMapper orders;
|
@Resource private LastlegOrderMapper orders;
|
||||||
@Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts;
|
|
||||||
|
|
||||||
/** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */
|
|
||||||
public List<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
|
||||||
return skuReceipts.listPlan(customerId, planId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */
|
|
||||||
public List<com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku> frozenSkus(String customerId, Long planId, Long lineId) {
|
|
||||||
return skuReceipts.frozenSkus(customerId, planId, lineId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */
|
|
||||||
public long fbaGroupLineCount(String customerId, Long planId, Long lineId) {
|
|
||||||
return skuReceipts.fbaGroupLineCount(customerId, planId, lineId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||||
public InboundPlan findPlan(String customerId, String planNo) {
|
public InboundPlan findPlan(String customerId, String planNo) {
|
||||||
|
|||||||
+1
-81
@@ -716,87 +716,7 @@ public class InboundPdaWorkflowService {
|
|||||||
repository.update(plan);
|
repository.update(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
/** Explicit operator decision to receive only the cartons actually picked. */
|
||||||
@Transactional
|
|
||||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
|
||||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
|
||||||
if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|
|
||||||
|| operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) {
|
|
||||||
throw conflict("短少确认必须提供操作人、请求键和确认原因");
|
|
||||||
}
|
|
||||||
InboundPlan original = repository.findPlan(customerId, planNo);
|
|
||||||
if (original == null) throw notFound("入库单不存在或不属于当前客户");
|
|
||||||
dispatchReceipts.lock(List.of(original.getId()));
|
|
||||||
InboundPlan plan = requirePlan(customerId, planNo);
|
|
||||||
// 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。
|
|
||||||
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
|
|
||||||
InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command);
|
|
||||||
if (replay != null) return read(replay, PdaResponses.Task.class);
|
|
||||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
|
||||||
if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null
|
|
||||||
|| !"PENDING_RECONCILIATION".equals(task.getStatus())) {
|
|
||||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
|
||||||
}
|
|
||||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
|
||||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
|
||||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
|
||||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
|
||||||
}
|
|
||||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
|
||||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
|
||||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
|
||||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
|
||||||
}
|
|
||||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
|
||||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
|
||||||
if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) {
|
|
||||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
|
||||||
}
|
|
||||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
|
||||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
|
||||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
|
||||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
|
||||||
var onlyLine = lines.get(0);
|
|
||||||
var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId());
|
|
||||||
if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1
|
|
||||||
|| frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank()
|
|
||||||
|| frozenSkus.get(0).packages() != expected) {
|
|
||||||
throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属");
|
|
||||||
}
|
|
||||||
if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null
|
|
||||||
|| !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) {
|
|
||||||
throw conflict("实收 SKU 与冻结申报不一致,不能确认短少");
|
|
||||||
}
|
|
||||||
for (InboundPlanLine line : lines) {
|
|
||||||
int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId()))
|
|
||||||
.mapToInt(a -> value(a.getPickedPackageCount())).sum();
|
|
||||||
int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId()))
|
|
||||||
.mapToInt(r -> value(r.getReceivedPackages())).sum();
|
|
||||||
if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) {
|
|
||||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
|
||||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
|
||||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
|
||||||
}
|
|
||||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
|
||||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
|
||||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
|
||||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
|
||||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
|
||||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
|
||||||
repository.update(task);
|
|
||||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
|
||||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
|
||||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
|
||||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
|
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
|
||||||
InboundPlan plan = requirePlan(customerId, planNo);
|
InboundPlan plan = requirePlan(customerId, planNo);
|
||||||
|
|||||||
+4
-13
@@ -48,7 +48,6 @@ public class InboundStockReceiptService {
|
|||||||
@Resource private DropshipOrderService dropshipOrders;
|
@Resource private DropshipOrderService dropshipOrders;
|
||||||
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
||||||
|
|
||||||
/** 按完整实收生成库存凭证。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||||
@@ -56,7 +55,6 @@ public class InboundStockReceiptService {
|
|||||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||||
@@ -105,7 +103,7 @@ public class InboundStockReceiptService {
|
|||||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||||
if (!exactSkuReceipts.isEmpty()) {
|
if (!exactSkuReceipts.isEmpty()) {
|
||||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||||
@@ -145,17 +143,14 @@ public class InboundStockReceiptService {
|
|||||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
|
||||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||||
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
||||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) {
|
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
|
||||||
if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank()
|
|
||||||
|| value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数");
|
|
||||||
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
|
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
|
||||||
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
|
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
|
||||||
|
|
||||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream()
|
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
|
||||||
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
||||||
java.util.LinkedHashMap::new));
|
java.util.LinkedHashMap::new));
|
||||||
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
||||||
@@ -164,11 +159,7 @@ public class InboundStockReceiptService {
|
|||||||
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
|
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
|
||||||
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
|
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
|
||||||
java.util.LinkedHashMap::new));
|
java.util.LinkedHashMap::new));
|
||||||
boolean invalid = allowPartial
|
if (!actualBySku.equals(normalizedExpected)) {
|
||||||
? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey())
|
|
||||||
|| entry.getValue() > normalizedExpected.get(entry.getKey()))
|
|
||||||
: !actualBySku.equals(normalizedExpected);
|
|
||||||
if (invalid) {
|
|
||||||
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
|
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -738,7 +738,7 @@ public class ReceivingContainerService {
|
|||||||
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
||||||
&& plannedOrders == totalOrders && planCount == totalOrders;
|
&& plannedOrders == totalOrders && planCount == totalOrders;
|
||||||
boolean allPlansCompleted = !matchedPlans.isEmpty()
|
boolean allPlansCompleted = !matchedPlans.isEmpty()
|
||||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||||
.contains(plan.getStatus()));
|
.contains(plan.getStatus()));
|
||||||
boolean shippingReady = facts == null
|
boolean shippingReady = facts == null
|
||||||
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
|
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
|
||||||
@@ -812,7 +812,7 @@ public class ReceivingContainerService {
|
|||||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||||
}
|
}
|
||||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||||
.contains(value.getStatus()))) {
|
.contains(value.getStatus()))) {
|
||||||
throw conflict("Receiving container is already completed");
|
throw conflict("Receiving container is already completed");
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -37,14 +37,6 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt
|
|||||||
for(var item:items) {
|
for(var item:items) {
|
||||||
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
||||||
var physical=repository.physical(item.planPalletId(),item.customerId());
|
var physical=repository.physical(item.planPalletId(),item.customerId());
|
||||||
// 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。
|
|
||||||
if(item.packages()==0) {
|
|
||||||
if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0)
|
|
||||||
|| original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId()))
|
|
||||||
throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配");
|
|
||||||
binding.removeItem(dispatch,item.itemId());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||||
var quantities=new LinkedHashMap<List<String>,Integer>();
|
var quantities=new LinkedHashMap<List<String>,Integer>();
|
||||||
for(var part:physical) {
|
for(var part:physical) {
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper {
|
|||||||
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
|
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
|
||||||
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
|
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
|
||||||
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
|
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
|
||||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded
|
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
|
||||||
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
||||||
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
||||||
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
||||||
|
|||||||
@@ -1192,7 +1192,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
||||||
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
||||||
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
||||||
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
|
incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
|
||||||
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
||||||
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
||||||
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
||||||
@@ -4658,17 +4658,3 @@ CREATE TABLE IF NOT EXISTS ll_document_sequence (
|
|||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
|
||||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
|
||||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
|
||||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
|
||||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
|
||||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
|
||||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
|
||||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
|
||||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
|
||||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
|
||||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
|
||||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: 排车装车资料(Admin/PDA共用)
|
title: 排车装车资料(Admin/PDA共用)
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
|
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
@@ -375,7 +375,7 @@ components:
|
|||||||
- DELIVERED
|
- DELIVERED
|
||||||
complete:
|
complete:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 五类各至少一张已保存照片,不能用总张数代替类别齐全
|
description: 五个固定框各有一张已保存照片
|
||||||
BindPhoto:
|
BindPhoto:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -392,8 +392,3 @@ components:
|
|||||||
maxLength: 128
|
maxLength: 128
|
||||||
fileId:
|
fileId:
|
||||||
type: string
|
type: string
|
||||||
replaceFileId:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
pattern: "^[1-9][0-9]*$"
|
|
||||||
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
|
|
||||||
|
|||||||
@@ -240,8 +240,3 @@ components:
|
|||||||
maxLength: 128
|
maxLength: 128
|
||||||
fileId:
|
fileId:
|
||||||
type: string
|
type: string
|
||||||
replaceFileId:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
pattern: "^[1-9][0-9]*$"
|
|
||||||
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "真实出车与自有外接运营统计",
|
"title": "真实出车与自有外接运营统计",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "仅DELIVERY派送且实际departed_at非空车次;PICKUP取货和LOCAL_TRUCK本土卡车使用不同执行事实,不纳入本派送统计;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -175,12 +175,6 @@ components:
|
|||||||
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
|
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
|
||||||
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
||||||
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
|
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
|
||||||
replaceFileId:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
pattern: "^[1-9][0-9]*$"
|
|
||||||
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
|
|
||||||
|
|
||||||
Vehicle:
|
Vehicle:
|
||||||
type: object
|
type: object
|
||||||
x-java-record: OutsourcedBusinessModels.Vehicle
|
x-java-record: OutsourcedBusinessModels.Vehicle
|
||||||
|
|||||||
@@ -500,12 +500,6 @@ components:
|
|||||||
fileId:
|
fileId:
|
||||||
type: "string"
|
type: "string"
|
||||||
pattern: "^[1-9][0-9]*$"
|
pattern: "^[1-9][0-9]*$"
|
||||||
replaceFileId:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
pattern: "^[1-9][0-9]*$"
|
|
||||||
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
|
|
||||||
|
|
||||||
Gate:
|
Gate:
|
||||||
type: "object"
|
type: "object"
|
||||||
x-java-record: "OwnedBusinessModels.Gate"
|
x-java-record: "OwnedBusinessModels.Gate"
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: 车辆与司机档案照片
|
|
||||||
version: 1.0.0
|
|
||||||
description: >-
|
|
||||||
档案照片使用独立版本并保留历史。车辆读取沿vehicle:list、上传沿vehicle:edit;
|
|
||||||
司机读取沿driver:list、上传沿driver:upload,均位于platform:overseas-master-data权限域。
|
|
||||||
只接受非空JPG/PNG,最大10MiB。排车保存时仅对缺失的车辆/司机照片复制当趟附件;
|
|
||||||
后续更换档案照片不覆盖已有排车照片,不创建临时外配主档,不改变驾驶证审核。
|
|
||||||
驾驶证仍走既有license接口;排车审核与主档统一接受PDF/JPG/PNG。
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
paths:
|
|
||||||
/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}:
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/Kind'}
|
|
||||||
- {$ref: '#/components/parameters/Id'}
|
|
||||||
get:
|
|
||||||
summary: 读取当前档案照片,未上传时版本为0且photo为空
|
|
||||||
responses:
|
|
||||||
'200': {$ref: '#/components/responses/Detail'}
|
|
||||||
'403': {description: 缺少该类档案list权限}
|
|
||||||
post:
|
|
||||||
summary: 上传或替换档案照片,照片版本冲突时不覆盖
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
multipart/form-data:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [recordVersion, file]
|
|
||||||
properties:
|
|
||||||
recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'}
|
|
||||||
file: {type: string, format: binary}
|
|
||||||
responses:
|
|
||||||
'200': {$ref: '#/components/responses/Detail'}
|
|
||||||
'403': {description: 缺少车辆edit或司机upload权限}
|
|
||||||
/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}/{fileId}/content:
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/Kind'}
|
|
||||||
- {$ref: '#/components/parameters/Id'}
|
|
||||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
|
||||||
get:
|
|
||||||
summary: 读取本档案已关联的当前或历史图片,no-store且无匿名URL
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 图片内容;业务错误为统一JSON响应,不可当成图片
|
|
||||||
content:
|
|
||||||
image/jpeg: {schema: {type: string, format: binary}}
|
|
||||||
image/png: {schema: {type: string, format: binary}}
|
|
||||||
'403': {description: 缺少该类档案list权限}
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
|
||||||
parameters:
|
|
||||||
Kind: {name: kind, in: path, required: true, schema: {type: string, enum: [VEHICLE, DRIVER]}}
|
|
||||||
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
|
||||||
responses:
|
|
||||||
Detail:
|
|
||||||
description: 业务成功code为200;版本冲突或归属无效通过业务错误返回
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [code, data]
|
|
||||||
properties:
|
|
||||||
code: {type: string}
|
|
||||||
message: {type: string}
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
required: [recordVersion, photo]
|
|
||||||
properties:
|
|
||||||
recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'}
|
|
||||||
photo:
|
|
||||||
type: object
|
|
||||||
nullable: true
|
|
||||||
required: [fileId, fileName, contentType, sizeBytes]
|
|
||||||
properties:
|
|
||||||
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
|
|
||||||
fileName: {type: string}
|
|
||||||
contentType: {type: string, enum: [image/jpeg, image/png]}
|
|
||||||
sizeBytes: {type: string, pattern: '^[1-9][0-9]*$'}
|
|
||||||
@@ -667,32 +667,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
|
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
|
||||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
|
||||||
post:
|
|
||||||
summary: 确认FBA入库短少并按实收入库
|
|
||||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
|
||||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [customerId, version, expectedPackageCount, receivedPackageCount, reason]
|
|
||||||
properties:
|
|
||||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
|
||||||
version: { type: integer, minimum: 0 }
|
|
||||||
expectedPackageCount: { type: integer, minimum: 1 }
|
|
||||||
receivedPackageCount: { type: integer, minimum: 1 }
|
|
||||||
reason: { type: string, minLength: 1, maxLength: 200 }
|
|
||||||
responses:
|
|
||||||
'200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND }
|
|
||||||
'403': { description: 非平台身份或无入库编辑权限 }
|
|
||||||
'404': { description: 入库单不存在或客户不匹配 }
|
|
||||||
'409': { description: 版本、状态、实收数量、SKU或幂等键冲突 }
|
|
||||||
/api/platform/v1/last-leg/inbound-plans/manual:
|
/api/platform/v1/last-leg/inbound-plans/manual:
|
||||||
post:
|
post:
|
||||||
summary: 平台按单票手工生成入库计划
|
summary: 平台按单票手工生成入库计划
|
||||||
|
|||||||
+20
-30
@@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
class DispatchCandidateMapperContractTest {
|
class DispatchCandidateMapperContractTest {
|
||||||
/** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */
|
/** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */
|
||||||
@Test
|
@Test
|
||||||
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
|
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
|
||||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
|
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
|
||||||
var db = connection.createStatement();
|
var db = connection.createStatement();
|
||||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')");
|
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)");
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
|
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||||
@@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest {
|
|||||||
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
|
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
|
||||||
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
|
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
|
||||||
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
|
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
|
||||||
for (int id = 1; id <= 14; id++) {
|
for (int id = 1; id <= 9; id++) {
|
||||||
db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')");
|
db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')");
|
||||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
|
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
|
||||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
|
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
|
||||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
|
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
|
||||||
@@ -44,34 +44,24 @@ class DispatchCandidateMapperContractTest {
|
|||||||
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
|
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
|
||||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
|
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
|
||||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
|
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
|
||||||
// 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。
|
|
||||||
db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)");
|
|
||||||
db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11");
|
|
||||||
db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)");
|
|
||||||
db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12");
|
|
||||||
db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13");
|
|
||||||
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14");
|
|
||||||
String plan = DispatchCandidateMapper.PLAN_ONLY;
|
String plan = DispatchCandidateMapper.PLAN_ONLY;
|
||||||
// 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。
|
String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) "
|
||||||
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
|
+ "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos "
|
||||||
int start = sql.indexOf("AND (NOT " + plan + " OR (");
|
+ "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))";
|
||||||
int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start);
|
var ids = new ArrayList<Long>();
|
||||||
int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate);
|
try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
||||||
String eligible = sql.substring(start, end).replace(">", ">")
|
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
||||||
.replace("#{customerId}", "'A'");
|
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
||||||
var ids = new ArrayList<Long>();
|
while (rows.next()) {
|
||||||
try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
long id = rows.getLong("id"); ids.add(id);
|
||||||
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
||||||
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class));
|
||||||
while (rows.next()) {
|
|
||||||
long id = rows.getLong("id"); ids.add(id);
|
|
||||||
assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
|
||||||
assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class));
|
|
||||||
if (id == 11) assertEquals(21, rows.getInt("actual_package_count"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。
|
}
|
||||||
assertEquals(List.of(1L, 3L, 7L, 11L), ids);
|
assertEquals(List.of(1L, 3L, 7L), ids);
|
||||||
|
// 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。
|
||||||
|
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
|
||||||
|
assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id")));
|
||||||
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
|
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -639,9 +639,6 @@ public class DispatchAggregatePersistenceTest {
|
|||||||
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
|
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
|
||||||
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
|
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
|
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
|
||||||
var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class);
|
|
||||||
when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null));
|
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence));
|
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
|
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
|
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||||
|
|||||||
+5
-14
@@ -17,9 +17,8 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
/** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */
|
/** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */
|
||||||
class DispatchFeedbackRegressionTest {
|
class DispatchFeedbackRegressionTest {
|
||||||
/** 主档PDF可以单独通过驾驶证校验,也可与本趟补传图片共存;司机照片自动带入。 */
|
/** 主档PDF与本趟补传图片分属各自文件域,补传不能被主档重建清空。 */
|
||||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
@Test void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner() throws Exception {
|
||||||
void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner(boolean supplement) throws Exception {
|
|
||||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3)))));
|
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||||
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
|
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
|
||||||
@@ -31,23 +30,15 @@ class DispatchFeedbackRegressionTest {
|
|||||||
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1));
|
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1));
|
||||||
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
|
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
|
||||||
when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3}));
|
when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3}));
|
||||||
var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class);
|
|
||||||
var photo=new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Photo("88","司机.png","image/png",3);
|
|
||||||
when(profilePhotos.current(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(1,photo));
|
|
||||||
when(profilePhotos.download(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71,88)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Download(photo,new byte[]{1,2,3}));
|
|
||||||
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
|
|
||||||
when(evidence.upload(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyString(),any())).thenReturn(new DispatchEvidenceGateway.File(303L,"司机.png","image/png",3));
|
|
||||||
ReflectionTestUtils.setField(f.service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,evidence));
|
|
||||||
var input=f.configured("update",List.of());
|
var input=f.configured("update",List.of());
|
||||||
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
|
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
|
||||||
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,supplement?List.of(91L):List.of(),input.driver().evidence().expectedTaskEndAt(),List.of())),
|
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))),
|
||||||
input.plannedDepartureAt(),null,null,null);
|
input.plannedDepartureAt(),null,null,null);
|
||||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||||
f.prepareAppointmentNumbers(created.dispatchNo());
|
f.prepareAppointmentNumbers(created.dispatchNo());
|
||||||
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
|
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
|
||||||
assertThat(proof.fileIds()).containsExactlyElementsOf(supplement?List.of(81L,91L):List.of(81L));assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
|
assertThat(proof.fileIds()).containsExactly(81L,91L);assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
|
||||||
assertThat(proof.photoFileIds()).containsExactly(303L);
|
assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
|
||||||
if(supplement)assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
|
|
||||||
assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion());
|
assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion());
|
||||||
verify(drivers,never()).download(71L,91L);
|
verify(drivers,never()).download(71L,91L);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-78
@@ -24,74 +24,6 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
||||||
class DispatchInboundReceiptPersistenceTest {
|
class DispatchInboundReceiptPersistenceTest {
|
||||||
/** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */
|
|
||||||
@Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2");
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21");
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2");
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
|
||||||
f.p.f.candidates.get(1L).packageCount(20);
|
|
||||||
f.p.f.candidates.get(2L).packageCount(0);
|
|
||||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
|
|
||||||
f.confirm(20,0);
|
|
||||||
assertThat(f.quantities()).containsExactly(20);
|
|
||||||
assertThat(f.allocations()).containsExactly(20);
|
|
||||||
var row=f.p.f.repository.find(f.p.number);
|
|
||||||
assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1);
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED");
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED");
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE");
|
|
||||||
var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version);
|
|
||||||
var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null)));
|
|
||||||
assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue();
|
|
||||||
verify(f.p.inventory).ship(eq("101"),eq("picker"),any());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */
|
|
||||||
@Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception {
|
|
||||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
|
||||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2");
|
|
||||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用");
|
|
||||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 零候选但没有明确零实收证明,不能作为空托删除。 */
|
|
||||||
@Test void missingEmptyReceiptEvidenceStillRejects() throws Exception {
|
|
||||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0);
|
|
||||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘");
|
|
||||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */
|
|
||||||
@Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
|
||||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9"));
|
|
||||||
f.confirm(0,0);
|
|
||||||
assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty();
|
|
||||||
var row=f.p.f.repository.find(f.p.number);
|
|
||||||
assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero();
|
|
||||||
assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT");
|
|
||||||
assertThat(row.getReviewBasisHash()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */
|
|
||||||
@Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception {
|
|
||||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
|
||||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
|
||||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1");
|
|
||||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源");
|
|
||||||
assertThat(f.quantities()).containsExactly(10,10);
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE");
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE");
|
|
||||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
/** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
||||||
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
|
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
|
||||||
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||||
@@ -121,8 +53,6 @@ class DispatchInboundReceiptPersistenceTest {
|
|||||||
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
|
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
|
||||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
|
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
|
||||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
|
||||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
|
||||||
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
||||||
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -269,7 +199,6 @@ class DispatchInboundReceiptPersistenceTest {
|
|||||||
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
|
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
|
||||||
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
|
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
|
||||||
for(long id=1;id<=2;id++) {
|
for(long id=1;id<=2;id++) {
|
||||||
doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9"));
|
|
||||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
|
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
|
||||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
|
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
|
||||||
doReturn(id).when(p.sources).lockPlan("101",id);
|
doReturn(id).when(p.sources).lockPlan("101",id);
|
||||||
@@ -308,15 +237,10 @@ class DispatchInboundReceiptPersistenceTest {
|
|||||||
|
|
||||||
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
||||||
void confirm() {
|
void confirm() {
|
||||||
confirm(9,11);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按指定真实数量验证短少和空托交接的同事务持久化。 */
|
|
||||||
void confirm(int first,int second) {
|
|
||||||
p.f.tx.executeWithoutResult(s->{
|
p.f.tx.executeWithoutResult(s->{
|
||||||
receipt.lock(List.of(1L,2L));
|
receipt.lock(List.of(1L,2L));
|
||||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second);
|
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
|
||||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second);
|
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
|
||||||
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
-27
@@ -183,33 +183,6 @@ class DispatchLoadingDeparturePersistenceTest {
|
|||||||
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同类可保存五张但不能充当五类;追加、替换、版本与幂等共同保护原照片。 */
|
|
||||||
@Test void multiplePhotosPerCategoryPreserveReferencesAndDepartureGate() throws Exception {
|
|
||||||
authorize();var f=new Fixture();
|
|
||||||
for(int i=0;i<5;i++) {
|
|
||||||
var request=new BindPhoto(Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i));
|
|
||||||
assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).complete()).isFalse();
|
|
||||||
}
|
|
||||||
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
|
|
||||||
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","too-many-photo","3000")))).hasMessageContaining("最多5张");
|
|
||||||
assertThat(f.documents.get(f.f.actor,f.number).recordVersion()).isEqualTo("5");
|
|
||||||
var replace=new BindPhoto("5","replace-photo","3000","2002");
|
|
||||||
var result=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace));
|
|
||||||
assertThat(result.photos()).extracting(p->p.fileId()).containsExactly("2000","2001","3000","2003","2004");
|
|
||||||
var replay=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace));
|
|
||||||
assertThat(replay).isEqualTo(result);
|
|
||||||
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,new BindPhoto("6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化");
|
|
||||||
for(int i=1;i<5;i++) {
|
|
||||||
var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=new BindPhoto(Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i));
|
|
||||||
f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,slot,request));
|
|
||||||
}
|
|
||||||
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(9);
|
|
||||||
assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue();
|
|
||||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
|
||||||
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("multi-ready"))).stage()).isEqualTo(Stage.DELIVERING);
|
|
||||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
|
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
|
||||||
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
|
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
|
||||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||||
|
|||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
package com.baiyue.lastleg.dispatch.service;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
|
||||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|
||||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode;
|
|
||||||
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import java.util.List;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
/** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */
|
|
||||||
class DispatchMasterPhotoServiceTest {
|
|
||||||
final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class);
|
|
||||||
final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class);
|
|
||||||
final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence);
|
|
||||||
@Test void copiesBothProfilesAndKeepsFrozenPhotosWithoutReadingChangedMaster() throws Exception {
|
|
||||||
var photo=new Photo("81","profile.png","image/png",3);
|
|
||||||
when(photos.current(any(),anyLong())).thenReturn(new Detail(1,photo));
|
|
||||||
when(photos.download(any(),anyLong(),eq(81L))).thenReturn(new Download(photo,new byte[]{1,2,3}));
|
|
||||||
when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(101L,"vehicle.png","image/png",3));
|
|
||||||
when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(102L,"driver.png","image/png",3));
|
|
||||||
var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of()),
|
|
||||||
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(),8L)),null);
|
|
||||||
var saved=service.populate(9,"op",resources);
|
|
||||||
assertThat(saved.vehicle().evidenceFileIds()).containsExactly(101L);
|
|
||||||
assertThat(saved.driver().evidence().photoFileIds()).containsExactly(102L);
|
|
||||||
assertThat(saved.driver().evidence().masterLicenseFileId()).isEqualTo(8L);
|
|
||||||
var file=ArgumentCaptor.forClass(MultipartFile.class);
|
|
||||||
verify(evidence).upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),file.capture());
|
|
||||||
assertThat(file.getValue().getBytes()).containsExactly(1,2,3);
|
|
||||||
clearInvocations(photos,evidence);
|
|
||||||
assertThat(service.populate(9,"op",saved)).isEqualTo(saved);
|
|
||||||
verifyNoInteractions(photos,evidence);
|
|
||||||
}
|
|
||||||
@Test void missingProfileRemainsMissingAndCopyFailureIsNotHidden() {
|
|
||||||
when(photos.current(Kind.VEHICLE,1)).thenReturn(new Detail(0,null));
|
|
||||||
assertThat(service.populate(9,"op",Kind.VEHICLE,1,List.of())).isEmpty();
|
|
||||||
verifyNoInteractions(evidence);
|
|
||||||
when(photos.current(Kind.VEHICLE,1)).thenThrow(new IllegalStateException("File unavailable"));
|
|
||||||
assertThatThrownBy(() -> service.populate(9,"op",Kind.VEHICLE,1,List.of())).hasMessage("File unavailable");
|
|
||||||
}
|
|
||||||
@Test void externalResourcesNeverReadOrCreateMasterProfiles() {
|
|
||||||
var resources=new Resources(new VehicleSnapshot(ResourceMode.EXTERNAL,null,"TEST",null,null,null,List.of()),
|
|
||||||
new DriverSnapshot(ResourceMode.EXTERNAL,null,"司机","电话",new DriverEvidence(null,null,List.of(),null,List.of())),null);
|
|
||||||
assertThat(service.populate(9,"op",resources)).isEqualTo(resources);
|
|
||||||
verifyNoInteractions(photos,evidence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest {
|
|||||||
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
|
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
|
||||||
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
|
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
|
||||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
|
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
|
||||||
for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) {
|
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
|
||||||
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
|
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
|
||||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
|
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
|
||||||
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
|
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
|
||||||
|
|||||||
+3
-3
@@ -148,13 +148,13 @@ class OwnedBusinessPersistenceTest {
|
|||||||
var original=new BindPhoto("0","bind-original","9");
|
var original=new BindPhoto("0","bind-original","9");
|
||||||
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
|
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
|
||||||
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
|
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
|
||||||
assertEquals("保存司机驾驶证照片",repository.revision(1,1).getReason());
|
assertEquals("保存装车现场照片",repository.revision(1,1).getReason());
|
||||||
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
|
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
|
||||||
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
|
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
|
||||||
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10","9"),replacement);
|
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement);
|
||||||
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
|
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
|
||||||
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
|
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
|
||||||
assertEquals("保存司机驾驶证照片",repository.revision(1,2).getReason());
|
assertEquals("保存装车现场照片",repository.revision(1,2).getReason());
|
||||||
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
|
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
|
||||||
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
|
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
|
||||||
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
|
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
|
||||||
|
|||||||
+1
-1
@@ -247,7 +247,7 @@ class OwnedBusinessScopePersistenceTest {
|
|||||||
}
|
}
|
||||||
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
|
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
|
||||||
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||||
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId(),firstFile));
|
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
|
||||||
final long oldId=Long.parseLong(firstFile);
|
final long oldId=Long.parseLong(firstFile);
|
||||||
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
|
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
|
||||||
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
|
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.util.List;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
class OwnedBusinessPhotoBindingsTest {
|
|
||||||
/** 保存油耗时不得压缩PDA同类多张装车照片。 */
|
|
||||||
@Test void fuelReplacementPreservesEveryLoadingPhoto() {
|
|
||||||
var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2"),photo(PhotoSlot.LOADING_LIST,"3"),photo(PhotoSlot.FUEL_START,"4"));
|
|
||||||
var saved=OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.FUEL_START,"5"),"4");
|
|
||||||
assertThat(saved).extracting(Photo::fileId).containsExactly("1","2","3","5");
|
|
||||||
assertThat(OwnedBusinessPhotoBindings.bind(saved,photo(PhotoSlot.FUEL_START,"6"),null)).extracting(Photo::fileId).containsExactly("1","2","3","6");
|
|
||||||
}
|
|
||||||
/** 同一文件不能重复绑定或借替换覆盖另一个类别。 */
|
|
||||||
@Test void rejectsDuplicateAndStaleReplacement() {
|
|
||||||
var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2"));
|
|
||||||
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"1"),null)).hasMessageContaining("重复");
|
|
||||||
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"2"),"1")).hasMessageContaining("重复");
|
|
||||||
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_LIST,"3"),"1")).hasMessageContaining("待替换");
|
|
||||||
}
|
|
||||||
/** 使用不含真实用户资料的固定文件元数据。 */
|
|
||||||
private Photo photo(PhotoSlot slot,String id) { return new Photo(slot,slot.category(),id,"test.png","image/png","5","2026-10-26T00:00:00Z","2026-10-26T00:00:00Z"); }
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.apache.ibatis.mapping.Environment;
|
|
||||||
import org.apache.ibatis.session.Configuration;
|
|
||||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
|
||||||
import org.h2.jdbcx.JdbcDataSource;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
/** 使用真实建表定义与Mapper验证类型隔离、最新版本及不可变历史。 */
|
|
||||||
class FleetResourcePhotoPersistenceTest {
|
|
||||||
@Test void preservesHistoryAndRejectsTwoWritesToSameVersion() throws Exception {
|
|
||||||
var source=new JdbcDataSource();source.setURL("jdbc:h2:mem:photos_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
|
||||||
var schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
|
||||||
var ddl=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ("));
|
|
||||||
try(var connection=source.getConnection();var statement=connection.createStatement()) { statement.execute(ddl); }
|
|
||||||
var config=new Configuration(new Environment("photos",new JdbcTransactionFactory(),source));
|
|
||||||
config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetResourcePhotoMapper.class);
|
|
||||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
|
||||||
var repository=new FleetResourcePhotoRepository(session.getMapper(FleetResourcePhotoMapper.class));
|
|
||||||
assertThat(repository.current(Kind.DRIVER,7)).isNull();
|
|
||||||
var at=LocalDateTime.of(2026,10,26,0,0);
|
|
||||||
repository.append(new Stored("DRIVER",7,1,81,"first.png","image/png",3,"op",at));
|
|
||||||
repository.append(new Stored("DRIVER",7,2,82,"second.png","image/png",4,"op",at));
|
|
||||||
assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82);
|
|
||||||
assertThat(repository.file(Kind.DRIVER,7,81).fileName()).isEqualTo("first.png");
|
|
||||||
assertThat(repository.file(Kind.VEHICLE,7,81)).isNull();
|
|
||||||
assertThat(repository.file(Kind.DRIVER,8,81)).isNull();
|
|
||||||
assertThatThrownBy(() -> repository.append(new Stored("DRIVER",7,2,83,"conflict.png","image/png",3,"op",at))).isInstanceOf(RuntimeException.class);
|
|
||||||
assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
|
||||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class FleetResourcePhotoServiceTest {
|
|
||||||
final FleetResourcePhotoRepository repository=mock(FleetResourcePhotoRepository.class);
|
|
||||||
final FleetResourcePhotoGateway files=mock(FleetResourcePhotoGateway.class);
|
|
||||||
final DriverService drivers=mock(DriverService.class);
|
|
||||||
final FleetResourcePhotoService service=new FleetResourcePhotoService(repository,files,mock(VehicleRepository.class),drivers);
|
|
||||||
final Photo photo=new Photo("81","driver.png","image/png",3);
|
|
||||||
final MockMultipartFile upload=new MockMultipartFile("file","driver.png","image/png",new byte[]{1,2,3});
|
|
||||||
@Test void staleVersionCannotUploadAndConcurrentReplacementCannotOverwrite() {
|
|
||||||
when(repository.current(Kind.DRIVER,2)).thenReturn(new Stored("DRIVER",2,1,81,"driver.png","image/png",3,"op",LocalDateTime.now()));
|
|
||||||
assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,0,"op",upload)).hasMessageContaining("已变化");
|
|
||||||
verifyNoInteractions(files);
|
|
||||||
when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo);
|
|
||||||
doThrow(new DuplicateKeyException("version")).when(repository).append(any());
|
|
||||||
assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,1,"op",upload)).hasMessageContaining("已变化");
|
|
||||||
}
|
|
||||||
@Test void fileFromAnotherProfileIsRejectedBeforeFileDownload() {
|
|
||||||
assertThatThrownBy(() -> service.download(Kind.DRIVER,2,81)).hasMessageContaining("不存在");
|
|
||||||
verifyNoInteractions(files);
|
|
||||||
}
|
|
||||||
@Test void firstUploadCreatesIndependentVersionAndDoesNotChangeDriverReview() {
|
|
||||||
when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo);
|
|
||||||
assertThat(service.upload(Kind.DRIVER,2,0,"op",upload)).isEqualTo(new Detail(1,photo));
|
|
||||||
verify(drivers).detail(2,null);
|
|
||||||
verifyNoMoreInteractions(drivers);
|
|
||||||
verify(repository).append(argThat(row -> row.recordVersion()==1 && row.resourceId()==2 && row.fileId()==81));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.*;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
class HttpFleetResourcePhotoGatewayTest {
|
|
||||||
@Test void validatesResourceKindOwnerIdentityAndImageType() {
|
|
||||||
var data=new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L);
|
|
||||||
var envelope=new HttpFleetResourcePhotoGateway.Envelope("200",data);
|
|
||||||
assertThat(HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,81L).fileId()).isEqualTo("81");
|
|
||||||
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,VEHICLE,7,81L)).hasMessageContaining("不属于");
|
|
||||||
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,8,81L)).hasMessageContaining("不属于");
|
|
||||||
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,82L)).hasMessageContaining("不属于");
|
|
||||||
for(var invalid : new HttpFleetResourcePhotoGateway.Metadata[]{
|
|
||||||
new HttpFleetResourcePhotoGateway.Metadata(81L,1L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L),
|
|
||||||
new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.pdf","application/pdf",3L)}) {
|
|
||||||
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(new HttpFleetResourcePhotoGateway.Envelope("200",invalid),DRIVER,7,81L)).hasMessageContaining("不属于");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.photo;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.context.annotation.*;
|
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import java.util.List;
|
|
||||||
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
/** 使用真实方法鉴权代理验证资源类型不能借用另一类档案权限。 */
|
|
||||||
class PlatformFleetResourcePhotoControllerTest {
|
|
||||||
@Configuration @EnableMethodSecurity
|
|
||||||
static class Config {
|
|
||||||
@Bean FleetResourcePhotoService service() { return mock(FleetResourcePhotoService.class); }
|
|
||||||
@Bean PlatformFleetResourcePhotoController controller(FleetResourcePhotoService service) { return new PlatformFleetResourcePhotoController(service); }
|
|
||||||
}
|
|
||||||
@Test void listPermissionOnlyReadsItsOwnResourceKindAndCannotUpload() {
|
|
||||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
|
||||||
var service=context.getBean(FleetResourcePhotoService.class);
|
|
||||||
var controller=context.getBean(PlatformFleetResourcePhotoController.class);
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test","",List.of(new SimpleGrantedAuthority("platform:overseas-master-data:vehicle:list"))));
|
|
||||||
when(service.current(Kind.VEHICLE,7)).thenReturn(new Detail(0,null));
|
|
||||||
controller.current(Kind.VEHICLE,7);
|
|
||||||
verify(service).current(Kind.VEHICLE,7);
|
|
||||||
assertThatThrownBy(() -> controller.current(Kind.DRIVER,7)).isInstanceOf(AccessDeniedException.class);
|
|
||||||
assertThatThrownBy(() -> controller.upload(Kind.VEHICLE,7,0,null,null)).isInstanceOf(AccessDeniedException.class);
|
|
||||||
assertThatThrownBy(() -> controller.upload(Kind.DRIVER,7,0,null,null)).isInstanceOf(AccessDeniedException.class);
|
|
||||||
verifyNoMoreInteractions(service);
|
|
||||||
} finally { SecurityContextHolder.clearContext(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.reporting;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
|
|
||||||
class TripOperationsRepositoryTest {
|
|
||||||
private JdbcTemplate jdbc;
|
|
||||||
private TripOperationsRepository repository;
|
|
||||||
|
|
||||||
@BeforeEach void setUp() {
|
|
||||||
jdbc = new JdbcTemplate(new DriverManagerDataSource(
|
|
||||||
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
|
||||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
|
|
||||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
|
|
||||||
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
|
|
||||||
repository = new TripOperationsRepository(jdbc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test void pickupAndLocalTruckFactsDoNotBreakDeliveryReport() {
|
|
||||||
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
|
||||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
|
|
||||||
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
|
|
||||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
|
|
||||||
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
|
||||||
|
|
||||||
var result = service().report(query(null, null), false);
|
|
||||||
|
|
||||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TR-A");
|
|
||||||
assertThat(result.summary().trips()).isEqualTo(1);
|
|
||||||
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
|
||||||
assertThat(result.summary().ownPackages()).isEqualTo(11);
|
|
||||||
assertThat(result.summary().externalPackages()).isEqualTo(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
|
|
||||||
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
|
|
||||||
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
|
|
||||||
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
|
|
||||||
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
|
|
||||||
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
|
||||||
assertThat(repository.departures(query(1L, "COMPLETED")))
|
|
||||||
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test void missingDeliveryHistoryStillFailsInsteadOfSilentlyDroppingTheTrip() {
|
|
||||||
trip(1, "MISSING", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
|
||||||
assertThatThrownBy(() -> service().report(query(null, null), false))
|
|
||||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
|
|
||||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TripOperationsModels.Query query(Long warehouse, String status) {
|
|
||||||
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
|
|
||||||
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TripOperationsService service() {
|
|
||||||
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-13
@@ -31,7 +31,6 @@ class InboundPlanControllerTest {
|
|||||||
@Mock InboundPlanService service;
|
@Mock InboundPlanService service;
|
||||||
@Mock InboundFbaAppointmentService appointmentService;
|
@Mock InboundFbaAppointmentService appointmentService;
|
||||||
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
|
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
|
||||||
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
|
||||||
private InboundPlanController controller;
|
private InboundPlanController controller;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -40,18 +39,6 @@ class InboundPlanControllerTest {
|
|||||||
ReflectionTestUtils.setField(controller, "service", service);
|
ReflectionTestUtils.setField(controller, "service", service);
|
||||||
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
|
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
|
||||||
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
|
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
|
||||||
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
|
|
||||||
@Test void platformConfirmsShortageWithExplicitCustomer() {
|
|
||||||
var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少");
|
|
||||||
controller.confirmShortage(platform(), "IP-1", "key", request);
|
|
||||||
verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request);
|
|
||||||
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
|
||||||
assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class);
|
|
||||||
var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少");
|
|
||||||
assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
|
||||||
|
|
||||||
import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest;
|
|
||||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.*;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.*;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class InboundShortageConfirmationTest {
|
|
||||||
@Mock PdaWorkflowRepository repository;
|
|
||||||
@Mock InboundStockReceiptService stock;
|
|
||||||
@Mock InboundDispatchReceiptGateway dispatch;
|
|
||||||
@Mock InboundUnloadCompletedOutboxService outbox;
|
|
||||||
InboundPdaWorkflowService service;
|
|
||||||
InboundPlan plan;
|
|
||||||
InboundPdaTask task;
|
|
||||||
InboundPlanLine line;
|
|
||||||
InboundPlanPalletAllocation allocation;
|
|
||||||
final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0);
|
|
||||||
|
|
||||||
/** 准备已结束现场收货、应收21实收20的冻结事实。 */
|
|
||||||
@BeforeEach void setup() {
|
|
||||||
service = new InboundPdaWorkflowService();
|
|
||||||
ReflectionTestUtils.setField(service, "repository", repository);
|
|
||||||
ReflectionTestUtils.setField(service, "stockReceipts", stock);
|
|
||||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
|
|
||||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
|
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
|
||||||
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
|
|
||||||
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4);
|
|
||||||
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
|
|
||||||
task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt);
|
|
||||||
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
|
|
||||||
line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21);
|
|
||||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L);
|
|
||||||
allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20);
|
|
||||||
var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20);
|
|
||||||
when(repository.findPlan("100", "IP-1")).thenReturn(plan);
|
|
||||||
lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan);
|
|
||||||
lenient().when(repository.lockTask("100", 1L)).thenReturn(task);
|
|
||||||
lenient().when(repository.lines(1L)).thenReturn(List.of(line));
|
|
||||||
lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation));
|
|
||||||
lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku));
|
|
||||||
lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of(
|
|
||||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21)));
|
|
||||||
lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */
|
|
||||||
@Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() {
|
|
||||||
var request = request(4, 20);
|
|
||||||
var result = service.confirmShortage("100", "IP-1", "admin", "key", request);
|
|
||||||
assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount());
|
|
||||||
assertEquals(receivedAt, task.getCompletedAt());
|
|
||||||
var sequence = inOrder(dispatch, repository, stock);
|
|
||||||
sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1");
|
|
||||||
sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
|
|
||||||
sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any());
|
|
||||||
var audit = ArgumentCaptor.forClass(InboundPdaOperation.class);
|
|
||||||
verify(repository).insert(audit.capture());
|
|
||||||
assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱"));
|
|
||||||
when(repository.operation("100", "key")).thenReturn(audit.getValue());
|
|
||||||
assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request));
|
|
||||||
verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
|
||||||
verifyNoInteractions(outbox);
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request));
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 页面旧版本或旧实收数不能确认库存。 */
|
|
||||||
@Test void rejectsStaleVersionAndCounts() {
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
|
||||||
verifyNoInteractions(stock);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */
|
|
||||||
@Test void rejectsMissingSkuReceipts() {
|
|
||||||
when(repository.skuReceipts("100", 1L)).thenReturn(List.of());
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
|
||||||
verifyNoInteractions(stock);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */
|
|
||||||
@Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() {
|
|
||||||
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(
|
|
||||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20),
|
|
||||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1)));
|
|
||||||
var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
|
||||||
assertTrue(error.getReason().contains("单 SKU"));
|
|
||||||
verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any());
|
|
||||||
assertEquals("PENDING_RECONCILIATION",plan.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */
|
|
||||||
@Test void rejectsSharedFbaReceivingGroup() {
|
|
||||||
when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L);
|
|
||||||
assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
|
||||||
verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
|
||||||
@org.junit.jupiter.params.ParameterizedTest
|
|
||||||
@org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22})
|
|
||||||
void rejectsZeroCompleteAndExcessReceipts(int received) {
|
|
||||||
allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received);
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received)));
|
|
||||||
verifyNoInteractions(stock);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */
|
|
||||||
@Test void rejectsMixedDestinations() {
|
|
||||||
var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS");
|
|
||||||
when(repository.lines(1L)).thenReturn(List.of(line, other));
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
|
||||||
verifyNoInteractions(stock);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 库存落账失败时不推进计划状态和运输交接。 */
|
|
||||||
@Test void doesNotAdvanceWhenStockInFails() {
|
|
||||||
doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
|
||||||
assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
|
||||||
assertEquals("PENDING_RECONCILIATION", plan.getStatus());
|
|
||||||
verify(dispatch, never()).complete(anyList(), anyString(), any());
|
|
||||||
verify(repository, never()).insert(any(InboundPdaOperation.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
|
||||||
@Test void rejectsWrongCustomer() {
|
|
||||||
when(repository.findPlan("100", "IP-1")).thenReturn(null);
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
|
||||||
verifyNoInteractions(stock, dispatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前实收值随请求一起参与版本及幂等校验。 */
|
|
||||||
private ConfirmInboundShortageRequest request(int version, int received) {
|
|
||||||
return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-28
@@ -324,34 +324,6 @@ class InboundStockReceiptServiceTest {
|
|||||||
List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10)));
|
List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */
|
|
||||||
@Test void exactSkuShortageStocksOnlyActualPackages() {
|
|
||||||
Fixture f = fixture(21, 21, 20, 210);
|
|
||||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
|
||||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20)));
|
|
||||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
|
||||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false));
|
|
||||||
service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true);
|
|
||||||
var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
|
||||||
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
|
|
||||||
assertEquals(20, request.getValue().packages());
|
|
||||||
assertEquals(21, f.detail.getPackageCount());
|
|
||||||
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
|
|
||||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */
|
|
||||||
@Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() {
|
|
||||||
Fixture f = fixture(21, 21, 20, 210);
|
|
||||||
f.detail.setPackageCount(10);
|
|
||||||
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110)));
|
|
||||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
|
||||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9)));
|
|
||||||
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task,
|
|
||||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true));
|
|
||||||
verify(inventory, never()).stockIn(any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
||||||
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
|
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
|
||||||
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
|
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
|
||||||
|
|||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
||||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
|
||||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mybatis.spring.SqlSessionTemplate;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
||||||
import java.util.UUID;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */
|
|
||||||
class ShortageReceiptEvidencePersistenceTest {
|
|
||||||
/** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */
|
|
||||||
@Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)");
|
|
||||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder(
|
|
||||||
new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1));
|
|
||||||
assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty();
|
|
||||||
f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'");
|
|
||||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */
|
|
||||||
@Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception {
|
|
||||||
var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
|
||||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')");
|
|
||||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')");
|
|
||||||
f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')");
|
|
||||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2);
|
|
||||||
f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22");
|
|
||||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
|
||||||
f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22");
|
|
||||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
|
||||||
f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22");
|
|
||||||
f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20");
|
|
||||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
|
||||||
assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */
|
|
||||||
@Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
|
||||||
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1);
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero();
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero();
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero();
|
|
||||||
f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
|
||||||
f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
|
||||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
|
||||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
|
||||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0");
|
|
||||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')");
|
|
||||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Fixture {
|
|
||||||
final JdbcTemplate db;
|
|
||||||
final ReceivingSkuReceiptMapper skus;
|
|
||||||
final DispatchSourceMapper dispatch;
|
|
||||||
|
|
||||||
/** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */
|
|
||||||
Fixture() throws Exception {
|
|
||||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
|
||||||
db=new JdbcTemplate(ds);
|
|
||||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)");
|
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
|
||||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)");
|
|
||||||
db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)");
|
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)");
|
|
||||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)");
|
|
||||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)");
|
|
||||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')");
|
|
||||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')");
|
|
||||||
db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')");
|
|
||||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
|
||||||
config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class);
|
|
||||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
|
||||||
var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-2
@@ -33,8 +33,6 @@ class OrderWorkflowFactMapperTest {
|
|||||||
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
|
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
|
||||||
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
|
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
|
||||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
|
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
|
||||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
|
||||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue());
|
|
||||||
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
|
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
|
||||||
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
|
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
|
||||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
|
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
|
||||||
| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README |
|
| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README |
|
||||||
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
|
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray/gcp-test、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
|
||||||
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
|
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
|
||||||
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
|
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
|
||||||
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
|
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。
|
- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。
|
||||||
- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。
|
- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。
|
||||||
- 开发目录只使用开发环境文件和开发域名,测试目录只使用测试环境文件和测试域名;不得跨目录复制报告、fixture 或未提交脚本来替代正常分支合并。
|
- 开发目录只使用开发环境文件和开发域名,测试目录只使用测试环境文件和测试域名;不得跨目录复制报告、fixture 或未提交脚本来替代正常分支合并。
|
||||||
|
- 用户明确授权从当前工作区验收谷歌测试时,单票入口显式使用仓库外 `gcp-test/acceptance.env` 与 `CHONGCHONG_ENV=gcp-test`;不得回退或混用 dev/test/gray 配置。检查点与测量回执保持 GCP 环境身份,不能冒充 TEST 或 GRAY;此例外不授权 SQL 重置或部署。
|
||||||
- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。
|
- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。
|
||||||
- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。
|
- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。
|
||||||
- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。
|
- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。
|
||||||
|
|||||||
+5
-3
@@ -234,7 +234,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
|
|||||||
|
|
||||||
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
|
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
|
||||||
- `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
|
- `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
|
||||||
- 必填 `CHONGCHONG_ENV=test` 或 `gray`;`CHONGCHONG_ADMIN_API_BASE_URL` 与 `CHONGCHONG_MERCHANT_API_BASE_URL` 必须完全指向同一 HTTPS `/api` 统一入口。拒绝 `prod/dev` 及旧 `CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL` 配置(即使值与统一入口相同也必须移除)。所有业务及文件上传只使用登录返回的 `accessToken`,不读取海外 Token。灰度由运行者显式选择外部灰度配置,不自动读取、修改或回退环境文件。
|
- 必填 `CHONGCHONG_ENV=test`、`gray` 或 `gcp-test`;`CHONGCHONG_ADMIN_API_BASE_URL` 与 `CHONGCHONG_MERCHANT_API_BASE_URL` 必须完全指向同一 HTTPS `/api` 统一入口。拒绝 `prod/dev` 及旧 `CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL` 配置(即使值与统一入口相同也必须移除)。所有业务及文件上传只使用登录返回的 `accessToken`,不读取海外 Token。灰度和谷歌测试由运行者显式选择对应的外部配置,谷歌测试使用 `gcp-test/acceptance.env` 并保留真实环境绑定,不改写为 test;不自动读取、修改或回退环境文件。
|
||||||
- 客户必须为 `ACTIVE` 且箱规初始化为 `READY`;`PROFILE_REQUIRED` 等状态阻断,不自动完善资料或审核客户。客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
|
- 客户必须为 `ACTIVE` 且箱规初始化为 `READY`;`PROFILE_REQUIRED` 等状态阻断,不自动完善资料或审核客户。客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
|
||||||
- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
|
- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
|
||||||
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
|
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
|
||||||
@@ -244,8 +244,10 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
|
|||||||
- 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。
|
- 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。
|
||||||
- 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。
|
- 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。
|
||||||
- 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。
|
- 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。
|
||||||
- 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和最终测量;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。
|
- 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY"|"GCP-TEST",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和核验测量;现行服务只接收版本、幂等键、清单摘要和备注,不发送客户端 measurements,服务端确认值必须与实测核验一致;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。
|
||||||
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。
|
- 谷歌测试合成装车必须由当轮授权后在首票输入显式设置 `syntheticLoading:true` 和 `syntheticOnly:true`,入口环境严格为 `gcp-test`;与 `loadingMeasurementFile`、`loadingMeasurements` 互斥,拒绝 `syntheticLoadingMeasurements` 数值输入。此模式不要求现场实测回执,也不构造 `actual:true` 或向服务发送虚假测量。按正式预览核对本票唯一托盘、拣货数量及车次卸货站,检查点与备注标记 `SYNTHETIC_TEST`、`actual:false`,结果 `realMeasurementVerified:false`、`scope:ONE_ORDER_SYNTHETIC_HTTP_PATH`;默认真实测量来源门禁不变。开关随输入及阶段指纹冻结,不能续跑时切换模式。
|
||||||
|
- 当前原生排车合同先上传本车车辆、司机合成照片并版本化绑定资源,再送两级审核;拣货后分别上传并保存 `LOADING_SCENE/LOADING_LIST/PRE_DEPARTURE_CHECK/DRIVER_LICENSE/RECEIPT` 五槽合成资料。资料版本与排车版本分开使用;五槽齐全后 `loading-complete` 自动准备派送和发车,脚本不再调用已删除的 `delivery-arrangement/transfer-delivery/PDA depart`。按主单 `documents` 回查唯一客户出库/派送及入仓来源,自动派送没有批次备注,以冻结业务关联和客户/仓库/箱数/来源逐项核验。所有上传/绑定/装车均写前落盘;未知结果停止且不自动重发。
|
||||||
|
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,默认范围为 `ONE_ORDER_NORMAL_HTTP_PATH`,显式合成装车范围为 `ONE_ORDER_SYNTHETIC_HTTP_PATH`,并单独报告装车来源及实测核验标志。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。
|
||||||
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。
|
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。
|
||||||
|
|
||||||
订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。单票运行示例(只到已审核,执行前确认授权,不代表全链完成):
|
订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。单票运行示例(只到已审核,执行前确认授权,不代表全链完成):
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function baseUrl(value, path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function config(env) {
|
export function config(env) {
|
||||||
if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray')
|
if (!['test', 'gray', 'gcp-test'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test、gray 或 gcp-test')
|
||||||
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
|
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
|
||||||
if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
|
if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,14 +114,14 @@ test('execute, resume and unrecognized arguments fail closed', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('explicit gray/test mode shares one API base and rejects retired environment keys', () => {
|
test('explicit gray/test/gcp-test mode shares one API base and rejects retired environment keys', () => {
|
||||||
for (const mode of ['gray', 'test']) {
|
for (const mode of ['gray', 'test', 'gcp-test']) {
|
||||||
const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
|
const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
|
||||||
assert.equal(settings.environment, mode)
|
assert.equal(settings.environment, mode)
|
||||||
assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
|
assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
|
||||||
assert.equal(settings.overseas, undefined)
|
assert.equal(settings.overseas, undefined)
|
||||||
}
|
}
|
||||||
for (const mode of ['prod', 'dev', '', undefined]) {
|
for (const mode of ['prod', 'dev', 'GCP-TEST', 'gcp', '', undefined]) {
|
||||||
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
|
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
|
||||||
}
|
}
|
||||||
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
|
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
|
||||||
@@ -129,6 +129,21 @@ test('explicit gray/test mode shares one API base and rejects retired environmen
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('gcp-test preserves its explicit target and cannot fall back to a different customer gateway', () => {
|
||||||
|
const env = { ...environment(), CHONGCHONG_ENV: 'gcp-test',
|
||||||
|
CHONGCHONG_ADMIN_API_BASE_URL: 'https://gcp-fixture.invalid/api',
|
||||||
|
CHONGCHONG_MERCHANT_API_BASE_URL: 'https://gcp-fixture.invalid/api' }
|
||||||
|
assert.equal(config(env).environment, 'gcp-test')
|
||||||
|
assert.equal(config(env).apiBase, 'https://gcp-fixture.invalid/api')
|
||||||
|
assert.throws(() => config({ ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://other-fixture.invalid/api' }), /同一 HTTPS/)
|
||||||
|
for (const value of ['http://gcp-fixture.invalid/api', 'https://gcp-fixture.invalid/', undefined]) {
|
||||||
|
assert.throws(() => config({ ...env, CHONGCHONG_ADMIN_API_BASE_URL: value }))
|
||||||
|
}
|
||||||
|
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
|
||||||
|
assert.throws(() => config({ ...env, [key]: env.CHONGCHONG_ADMIN_API_BASE_URL }), /旧海外网关/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
|
test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
|
||||||
const calls = []
|
const calls = []
|
||||||
const http = client(config(environment()), async (url, options) => {
|
const http = client(config(environment()), async (url, options) => {
|
||||||
|
|||||||
@@ -39,18 +39,27 @@ export async function withDeliveryJournal(path, binding, action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function verifyOutbound(input, dispatch, detail, tail) {
|
export function verifyOutbound(input, dispatch, detail, tail) {
|
||||||
check(dispatch?.dispatchNo === input.dispatchNo && dispatch.overseasWarehouseId === input.warehouseId
|
const customerId = tail?.order?.customerId
|
||||||
&& dispatch.status === 'DEPARTED' && number(dispatch.packageCount) === input.packageCount
|
check(tail?.order?.id === input.tailOrderId && segment(tail.order.orderNo) && tail.order.sourceOrderNo === input.orderNo
|
||||||
&& number(dispatch.shippedPackageCount) === input.packageCount
|
&& tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符')
|
||||||
&& number(dispatch.pickedPackageCount) === input.packageCount
|
check(customerId && dispatch?.dispatchNo === input.dispatchNo && dispatch.status === 'ACTIVE'
|
||||||
&& number(dispatch.remainingPackageCount) === 0, '排车身份、发车状态或数量不符')
|
&& ['DELIVERING', 'DELIVERED'].includes(dispatch.stage) && dispatch.pickingReconciled === true
|
||||||
check(dispatch.items?.length > 0 && dispatch.items.every(item => item.inboundCode === input.inboundCode), '排车包含其他票')
|
&& number(dispatch.counts?.packageCount) === input.packageCount
|
||||||
|
&& number(dispatch.counts?.shippedPackageCount) === input.packageCount
|
||||||
|
&& number(dispatch.counts?.pickedPackageCount) === input.packageCount, '排车身份、发车状态或数量不符')
|
||||||
|
check(dispatch.orderDetails?.length > 0 && dispatch.orderDetails.every(item => item.inboundCode === input.inboundCode
|
||||||
|
&& item.customerId === customerId && item.orderNo === tail.order.orderNo), '排车包含其他票或客户')
|
||||||
|
check(dispatch.documents?.dispatchNo === input.dispatchNo && dispatch.documents.outbounds?.length === 1
|
||||||
|
&& dispatch.documents.outbounds[0].outboundNo === input.outboundNo && dispatch.documents.outbounds[0].customerId === customerId
|
||||||
|
&& dispatch.documents.deliveries?.length === 1 && dispatch.documents.deliveries[0].deliveryNo === input.deliveryNo
|
||||||
|
&& dispatch.documents.deliveries[0].outboundNo === input.outboundNo && dispatch.documents.deliveries[0].customerId === customerId,
|
||||||
|
'排车关联单据不是本客户本票唯一出库派送')
|
||||||
check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo
|
check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo
|
||||||
&& detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED'
|
&& detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED'
|
||||||
&& number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符')
|
&& number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符')
|
||||||
check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo, '不是本票唯一派送单')
|
check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo
|
||||||
check(tail?.order?.id === input.tailOrderId && tail.order.sourceOrderNo === input.orderNo
|
&& detail.deliveries[0].customerId === customerId && detail.deliveries[0].dispatchNo === input.dispatchNo
|
||||||
&& tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符')
|
&& detail.deliveries[0].outboundNo === input.outboundNo, '不是本票唯一派送单')
|
||||||
const blockers = []
|
const blockers = []
|
||||||
if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode
|
if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode
|
||||||
|| number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) {
|
|| number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) {
|
||||||
@@ -69,7 +78,8 @@ export async function runDeliveryStage({ input, customerId, state, save, request
|
|||||||
const detail = await request('overseas', 'GET', scoped(deliveryPath))
|
const detail = await request('overseas', 'GET', scoped(deliveryPath))
|
||||||
check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo
|
check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo
|
||||||
&& detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo
|
&& detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo
|
||||||
&& detail.order.remark === `RELEASE-ACCEPTANCE:${input.batch}`, '派送单身份或验收标记不符')
|
&& detail.order.overseasWarehouseId === input.warehouseId && number(detail.order.packageCount) === input.packageCount
|
||||||
|
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === input.inboundCode), '派送单身份、客户、源入仓或数量不符')
|
||||||
return detail
|
return detail
|
||||||
}
|
}
|
||||||
const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`)
|
const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutboun
|
|||||||
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
|
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
|
||||||
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
|
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const dispatch = { dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'DEPARTED', packageCount: 1,
|
const dispatch = { dispatchNo: 'DS-101', status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true,
|
||||||
shippedPackageCount: 1, pickedPackageCount: 1, remainingPackageCount: 0, items: [{ inboundCode: 'IN-101' }] }
|
counts: { packageCount: 1, shippedPackageCount: 1, pickedPackageCount: 1 },
|
||||||
|
orderDetails: [{ inboundCode: 'IN-101', customerId: '501', orderNo: 'WC-201' }],
|
||||||
|
documents: { dispatchNo: 'DS-101', outbounds: [{ customerId: '501', outboundNo: 'OB-101' }],
|
||||||
|
deliveries: [{ customerId: '501', outboundNo: 'OB-101', deliveryNo: 'DL-101' }] } }
|
||||||
const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 },
|
const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 },
|
||||||
deliveries: [{ deliveryNo: 'DL-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] }
|
deliveries: [{ deliveryNo: 'DL-101', customerId: '501', dispatchNo: 'DS-101', outboundNo: 'OB-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] }
|
||||||
const tail = { order: { id: '201', customerId: '501', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } }
|
const tail = { order: { id: '201', customerId: '501', orderNo: 'WC-201', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } }
|
||||||
return { dispatch, outbound, tail }
|
return { dispatch, outbound, tail }
|
||||||
}
|
}
|
||||||
function harness(state = { steps: {} }) {
|
function harness(state = { steps: {} }) {
|
||||||
@@ -29,7 +32,7 @@ function harness(state = { steps: {} }) {
|
|||||||
if (path.includes('/outbound-orders/')) return facts.outbound
|
if (path.includes('/outbound-orders/')) return facts.outbound
|
||||||
if (path.includes('/last-leg/orders/')) return facts.tail
|
if (path.includes('/last-leg/orders/')) return facts.tail
|
||||||
if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101',
|
if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101',
|
||||||
remark: 'RELEASE-ACCEPTANCE:acceptance-1234', status, version: 0 }, operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] }
|
remark: null, overseasWarehouseId: '301', packageCount: 1, status, version: 0 }, skus: [{ inboundCode: 'IN-101' }], operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] }
|
||||||
if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] }
|
if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] }
|
||||||
if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } }
|
if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } }
|
||||||
throw new Error('unexpected request')
|
throw new Error('unexpected request')
|
||||||
@@ -64,13 +67,36 @@ test('stale pallet detail blocks release despite successful dispatch and receipt
|
|||||||
assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE'])
|
assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE'])
|
||||||
})
|
})
|
||||||
test('wrong source order or additional inbound blocks before mutations', async () => {
|
test('wrong source order or additional inbound blocks before mutations', async () => {
|
||||||
for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.items.push({ inboundCode: 'OTHER' }) }]) {
|
for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.orderDetails.push({ inboundCode: 'OTHER', customerId: '501', orderNo: 'FL-101' }) }]) {
|
||||||
const h = harness(); mutate(h)
|
const h = harness(); mutate(h)
|
||||||
await assert.rejects(runDeliveryStage(h.options))
|
await assert.rejects(runDeliveryStage(h.options))
|
||||||
assert.equal(h.writes.length, 0)
|
assert.equal(h.writes.length, 0)
|
||||||
assert.equal(h.uploads(), 0)
|
assert.equal(h.uploads(), 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('dispatch binds the tail order number and tail binds the domestic source without either-or fallback', async () => {
|
||||||
|
const facts = fixture()
|
||||||
|
assert.notEqual(facts.tail.order.orderNo, input.orderNo)
|
||||||
|
assert.deepEqual(verifyOutbound(input, facts.dispatch, facts.outbound, facts.tail), [])
|
||||||
|
for (const mutate of [
|
||||||
|
h => { h.facts.dispatch.orderDetails[0].orderNo = input.orderNo },
|
||||||
|
h => { h.facts.dispatch.orderDetails[0].orderNo = 'WC-OTHER' },
|
||||||
|
h => { h.facts.dispatch.orderDetails.push({ ...h.facts.dispatch.orderDetails[0], orderNo: 'WC-OTHER' }) },
|
||||||
|
h => { h.facts.tail.order.sourceOrderNo = h.facts.tail.order.orderNo },
|
||||||
|
h => { delete h.facts.tail.order.orderNo; delete h.facts.dispatch.orderDetails[0].orderNo },
|
||||||
|
h => { h.facts.tail.order.orderNo = ''; h.facts.dispatch.orderDetails[0].orderNo = '' },
|
||||||
|
h => { h.facts.tail.order.id = '999' },
|
||||||
|
h => { h.facts.tail.order.inboundCode = 'OTHER' },
|
||||||
|
h => { h.facts.tail.order.overseasWarehouseId = '999' },
|
||||||
|
]) {
|
||||||
|
const h = harness(); mutate(h)
|
||||||
|
await assert.rejects(runDeliveryStage(h.options))
|
||||||
|
assert.equal(h.writes.length, 0)
|
||||||
|
assert.equal(h.uploads(), 0)
|
||||||
|
assert.deepEqual(h.options.state, { steps: {} })
|
||||||
|
}
|
||||||
|
})
|
||||||
test('uncertain upload and uncertain status mutation are never resent', async () => {
|
test('uncertain upload and uncertain status mutation are never resent', async () => {
|
||||||
const h = harness({ uploadIntent: true })
|
const h = harness({ uploadIntent: true })
|
||||||
await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/)
|
await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/)
|
||||||
@@ -158,3 +184,24 @@ test('delivery executor uses one gateway/accessToken for status, multipart recei
|
|||||||
assert.equal(result.fullFlowVerified, false)
|
assert.equal(result.fullFlowVerified, false)
|
||||||
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
|
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('native auto delivery rejects foreign customer, dispatch, outbound and source before receipt writes', async () => {
|
||||||
|
for (const patch of [{ customerId: '999' }, { dispatchNo: 'OTHER' }, { outboundNo: 'OTHER' }, { overseasWarehouseId: '999' }, { packageCount: 2 }]) {
|
||||||
|
const h = harness(), request = h.options.request
|
||||||
|
h.options.request = async (...args) => { const data = await request(...args); if (data?.order?.deliveryNo) Object.assign(data.order, patch); return data }
|
||||||
|
await assert.rejects(runDeliveryStage(h.options), /派送单身份/)
|
||||||
|
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
|
||||||
|
}
|
||||||
|
for (const mutate of [h => { h.facts.dispatch.orderDetails[0].customerId = '999' },
|
||||||
|
h => { h.facts.dispatch.documents.deliveries[0].outboundNo = 'OTHER' },
|
||||||
|
h => { h.facts.outbound.deliveries[0].customerId = '999' }]) {
|
||||||
|
const h = harness(); mutate(h)
|
||||||
|
await assert.rejects(runDeliveryStage(h.options))
|
||||||
|
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
|
||||||
|
}
|
||||||
|
const h = harness(), request = h.options.request
|
||||||
|
h.options.request = async (...args) => { const data = await request(...args); if (data?.skus) data.skus[0].inboundCode = 'OTHER'; return data }
|
||||||
|
await assert.rejects(runDeliveryStage(h.options), /源入仓/)
|
||||||
|
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import { executeOrder } from './prod-release-acceptance.mjs'
|
|||||||
import { executeDelivery } from './prod-release-delivery-stage.mjs'
|
import { executeDelivery } from './prod-release-delivery-stage.mjs'
|
||||||
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
|
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
|
||||||
import { runDomesticStage } from './prod-release-domestic-stage.mjs'
|
import { runDomesticStage } from './prod-release-domestic-stage.mjs'
|
||||||
import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
|
import { runOverseasStage, finishSingleOrderTrip, validateSyntheticLoadingInput } from './prod-release-overseas-stage.mjs'
|
||||||
|
|
||||||
|
export function loadingVerificationResult(overseas) {
|
||||||
|
return { loadingMeasurementMode: overseas.loadingMeasurementMode, realMeasurementVerified: overseas.realMeasurementVerified === true,
|
||||||
|
scope: overseas.loadingMeasurementMode === 'SYNTHETIC_TEST' ? 'ONE_ORDER_SYNTHETIC_HTTP_PATH' : 'ONE_ORDER_NORMAL_HTTP_PATH' }
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
|
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
|
||||||
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
|
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
|
||||||
environment: { mode: settings.environment, apiBase: settings.apiBase },
|
environment: { mode: settings.environment, apiBase: settings.apiBase },
|
||||||
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
|
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
|
||||||
validateOrderInput(input)
|
validateOrderInput(input)
|
||||||
|
validateSyntheticLoadingInput(input)
|
||||||
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
|
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
|
||||||
return withFlowJournal(journalPath, binding, async (state, save) => {
|
return withFlowJournal(journalPath, binding, async (state, save) => {
|
||||||
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
|
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
|
||||||
@@ -73,7 +79,7 @@ export async function executeFullFlow(settings, rawInput, journalPath, resume =
|
|||||||
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`)
|
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`)
|
||||||
const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
|
const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
|
||||||
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
|
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
|
||||||
scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode,
|
...loadingVerificationResult(overseas), orderNo: order.orderNo, inboundCode: order.inboundCode,
|
||||||
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
|
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
|
||||||
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
|
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
|
||||||
tripClosureStatus: tripClosure.status,
|
tripClosureStatus: tripClosure.status,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function binding(input) {
|
|||||||
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
|
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
|
||||||
export async function withOrderJournal(path, input, action) {
|
export async function withOrderJournal(path, input, action) {
|
||||||
validateOrderInput(input)
|
validateOrderInput(input)
|
||||||
assert(['test', 'gray'].includes(input.environment?.mode)
|
assert(['test', 'gray', 'gcp-test'].includes(input.environment?.mode)
|
||||||
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
|
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
|
||||||
await mkdir(dirname(path), { recursive: true })
|
await mkdir(dirname(path), { recursive: true })
|
||||||
const lock = await open(`${path}.lock`, 'wx', 0o600)
|
const lock = await open(`${path}.lock`, 'wx', 0o600)
|
||||||
|
|||||||
@@ -26,6 +26,26 @@ async function sandbox(t) {
|
|||||||
t.after(() => rm(path, { recursive: true, force: true }))
|
t.after(() => rm(path, { recursive: true, force: true }))
|
||||||
return join(path, 'journal.json')
|
return join(path, 'journal.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('gcp-test journal resumes only with the same environment and unified gateway', async t => {
|
||||||
|
const path = await sandbox(t)
|
||||||
|
const input = fixture()
|
||||||
|
input.environment = { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' }
|
||||||
|
await withOrderJournal(path, input, async (state, save) => {
|
||||||
|
assert.equal(state.phase, 'NEW')
|
||||||
|
await save({})
|
||||||
|
})
|
||||||
|
const original = await readFile(path, 'utf8')
|
||||||
|
for (const environment of [
|
||||||
|
{ ...input.environment, mode: 'test' },
|
||||||
|
{ ...input.environment, mode: 'gray' },
|
||||||
|
{ ...input.environment, apiBase: 'https://other-fixture.invalid/api' },
|
||||||
|
]) {
|
||||||
|
await assert.rejects(withOrderJournal(path, { ...input, environment }, () => assert.fail('must not run')), /环境、客户、账号或载荷已变化/)
|
||||||
|
assert.equal(await readFile(path, 'utf8'), original)
|
||||||
|
}
|
||||||
|
await withOrderJournal(path, input, state => assert.equal(state.phase, 'NEW'))
|
||||||
|
})
|
||||||
function server(input, fail) {
|
function server(input, fail) {
|
||||||
let status = null
|
let status = null
|
||||||
let failed = false
|
let failed = false
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const PDA = '/api/pda/v1/last-leg'
|
|||||||
const check = (ok, message) => { if (!ok) throw new Error(message) }
|
const check = (ok, message) => { if (!ok) throw new Error(message) }
|
||||||
const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) }
|
const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) }
|
||||||
const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value }
|
const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value }
|
||||||
const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_V1'
|
const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_AUTO_DEPART_V2'
|
||||||
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
||||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||||
|
|
||||||
@@ -63,7 +63,17 @@ export function overseasCommands(context, checkpoint) {
|
|||||||
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
|
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Explicit, test-only alternative; never manufactures actual:true evidence. */
|
||||||
|
export function validateSyntheticLoadingInput(input) {
|
||||||
|
check(input.syntheticLoadingMeasurements == null, '当前装车接口不接受客户端测量数值;合成验收使用syntheticLoading=true')
|
||||||
|
if (input.syntheticLoading == null || input.syntheticLoading === false) return
|
||||||
|
check(input.syntheticLoading === true && input.environment?.mode === 'gcp-test' && input.syntheticOnly === true,
|
||||||
|
'合成装车仅允许显式gcp-test且syntheticLoading=true、syntheticOnly=true')
|
||||||
|
check(input.loadingMeasurementFile == null && input.loadingMeasurements == null, '合成装车不能同时提供真实测量文件或内联测量')
|
||||||
|
}
|
||||||
|
|
||||||
export function validateOverseasInput(input, domestic) {
|
export function validateOverseasInput(input, domestic) {
|
||||||
|
validateSyntheticLoadingInput(input)
|
||||||
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
|
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
|
||||||
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票')
|
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票')
|
||||||
for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
|
for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
|
||||||
@@ -71,7 +81,7 @@ export function validateOverseasInput(input, domestic) {
|
|||||||
check(Boolean(input.resources.carrierName), '缺少既有车队名称')
|
check(Boolean(input.resources.carrierName), '缺少既有车队名称')
|
||||||
check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供')
|
check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供')
|
||||||
if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径')
|
if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径')
|
||||||
if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray'].includes(input.environment?.mode), '真实测量缺少本次TEST或GRAY环境绑定')
|
if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray', 'gcp-test'].includes(input.environment?.mode), '真实测量缺少本次TEST、GRAY或GCP-TEST环境绑定')
|
||||||
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`)
|
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`)
|
||||||
for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
|
for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
|
||||||
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
|
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
|
||||||
@@ -85,6 +95,10 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
|
|||||||
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
|
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
|
||||||
const { state, save } = checkpoint
|
const { state, save } = checkpoint
|
||||||
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
|
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
|
||||||
|
const loadingModeBinding = fingerprint({ synthetic: input.syntheticLoading === true, syntheticOnly: input.syntheticOnly === true })
|
||||||
|
check(!state.loadingModeBinding || state.loadingModeBinding === loadingModeBinding, '装车模式或合成值与冻结检查点不符')
|
||||||
|
check(!input.syntheticLoading || !state.loadingMeasurementEvidence || state.loadingMeasurementEvidence.source === 'SYNTHETIC_TEST', '不能将既有真实测量检查点改为合成装车')
|
||||||
|
if (!state.loadingModeBinding) { await save({ loadingModeBinding }); state.loadingModeBinding = loadingModeBinding }
|
||||||
check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符')
|
check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符')
|
||||||
const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart']
|
const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart']
|
||||||
check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name])
|
check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name])
|
||||||
@@ -92,7 +106,8 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
|
|||||||
if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL }
|
if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL }
|
||||||
const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
|
const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
|
||||||
const pdaToken = context.overseasPdaToken
|
const pdaToken = context.overseasPdaToken
|
||||||
const remark = `RELEASE-ACCEPTANCE:${input.batch}`
|
const syntheticLoading = input.syntheticLoading === true
|
||||||
|
const remark = `RELEASE-ACCEPTANCE:${input.batch}${syntheticLoading ? ':SYNTHETIC_TEST:actual=false' : ''}`
|
||||||
const key = name => `${input.batch}-${name}`
|
const key = name => `${input.batch}-${name}`
|
||||||
if (!state.schedule) {
|
if (!state.schedule) {
|
||||||
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
|
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
@@ -215,10 +230,57 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
|
|||||||
version(detail.recordVersion); return { detail, items: items.items }
|
version(detail.recordVersion); return { detail, items: items.items }
|
||||||
}
|
}
|
||||||
await ownMain()
|
await ownMain()
|
||||||
|
const vehiclePhoto = await command('vehicle-photo', 'POST', `${dispatchPath}/evidence/VEHICLE`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-vehicle.png`)
|
||||||
|
const driverPhoto = await command('driver-photo', 'POST', `${dispatchPath}/evidence/DRIVER_PHOTO`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-driver.png`)
|
||||||
|
check(/^[1-9]\d*$/.test(id(vehiclePhoto.fileId)) && /^[1-9]\d*$/.test(id(driverPhoto.fileId)), '本车合成资源照片上传回执缺少文件身份')
|
||||||
|
await recorded('resource-evidence', 'PUT', dispatchPath, async () => {
|
||||||
|
const { detail } = await ownMain()
|
||||||
|
check(detail.stage === 'BEFORE_LOADING', '资源照片绑定必须在送审前')
|
||||||
|
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('resource-evidence'),
|
||||||
|
vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId, evidenceFileIds: [id(vehiclePhoto.fileId)] },
|
||||||
|
driver: { mode: 'MASTER', driverId: input.resources.driverId,
|
||||||
|
evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt, photoFileIds: [id(driverPhoto.fileId)] } },
|
||||||
|
plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(),
|
||||||
|
remark: `${remark}:SYNTHETIC_TEST资源照片,非真实资质` }
|
||||||
|
})
|
||||||
|
const reviewDetail = (await ownMain()).detail
|
||||||
|
check(Array.isArray(reviewDetail.dispatchInfo?.fbaAppointments), '本车目的仓预约预览缺失')
|
||||||
|
const missingAppointments = reviewDetail.dispatchInfo.fbaAppointments.filter(row => !row.appointmentNo?.trim())
|
||||||
|
if (state.commands?.['destination-appointment'] || missingAppointments.length) {
|
||||||
|
await recorded('destination-appointment', 'PUT', dispatchPath, async () => {
|
||||||
|
check(syntheticLoading && input.syntheticOnly === true && input.environment?.mode === 'gcp-test',
|
||||||
|
'本车目的仓缺少正式预约号;仅显式GCP合成验收可登记合成预约')
|
||||||
|
const { detail } = await ownMain(), appointments = detail.dispatchInfo?.fbaAppointments
|
||||||
|
check(detail.stage === 'BEFORE_LOADING' && appointments?.length === 1 && input.destination?.type === 'FBA'
|
||||||
|
&& /^[A-Z0-9-]{1,32}$/.test(input.destination.code || '')
|
||||||
|
&& appointments[0].destinationKey === `FBA:${input.destination.code}`
|
||||||
|
&& appointments[0].destinationWarehouseCode === input.destination.code && !appointments[0].appointmentNo?.trim(),
|
||||||
|
'合成预约须绑定本票唯一正式FBA目的仓,不能猜测或覆盖已有预约')
|
||||||
|
const appointmentNo = `SYNTHETIC_TEST-${input.batch}-${input.destination.code}`
|
||||||
|
const destinationAppointments = [{ destinationKey: appointments[0].destinationKey, appointmentNo }]
|
||||||
|
const vehicle = detail.vehicleInfo.vehicle, driver = detail.vehicleInfo.driver
|
||||||
|
check(vehicle.mode === 'MASTER' && driver.mode === 'MASTER' && vehicle.evidenceFileIds?.length
|
||||||
|
&& driver.evidence?.photoFileIds?.length && detail.dispatchInfo.plannedDepartureAt && detail.dispatchInfo.estimatedArrivalAt,
|
||||||
|
'预约维护缺少本车已核验资源照片或运输时间')
|
||||||
|
const destinationAppointmentEvidence = { source: 'SYNTHETIC_TEST', actual: false, batch: input.batch, dispatchNo, destinationAppointments }
|
||||||
|
await save({ destinationAppointmentEvidence }); state.destinationAppointmentEvidence = destinationAppointmentEvidence
|
||||||
|
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('destination-appointment'),
|
||||||
|
vehicle: { mode: vehicle.mode, vehicleId: id(vehicle.vehicleId), plate: vehicle.plate, carrierName: vehicle.carrierName,
|
||||||
|
capacity: vehicle.capacity, evidenceFileIds: vehicle.evidenceFileIds },
|
||||||
|
driver: { mode: driver.mode, driverId: id(driver.driverId), name: driver.name, phone: driver.phone, evidence: driver.evidence },
|
||||||
|
plannedDepartureAt: detail.dispatchInfo.plannedDepartureAt, estimatedArrivalAt: detail.dispatchInfo.estimatedArrivalAt,
|
||||||
|
maintenance: { ...detail.vehicleInfo.maintenance, destinationAppointments },
|
||||||
|
remark: `${remark}:SYNTHETIC_TEST预约号,仅测试,不代表第三方确认` }
|
||||||
|
})
|
||||||
|
const current = (await ownMain()).detail.dispatchInfo.fbaAppointments
|
||||||
|
const expected = state.commands['destination-appointment'].request.body.maintenance.destinationAppointments
|
||||||
|
check(current?.length === expected.length && expected.every(row => current.some(saved =>
|
||||||
|
saved.destinationKey === row.destinationKey && saved.appointmentNo === row.appointmentNo)), '本车合成预约保存后回查不符')
|
||||||
|
}
|
||||||
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => {
|
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => {
|
||||||
const { detail } = await ownMain()
|
const { detail } = await ownMain()
|
||||||
check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符')
|
check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符')
|
||||||
return { recordVersion: detail.recordVersion, idempotencyKey: key(action),
|
return { recordVersion: detail.recordVersion, idempotencyKey: key(action === 'submit-review' ? 'submit-review-with-appointment' : action),
|
||||||
...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) }
|
...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) }
|
||||||
})
|
})
|
||||||
const candidates = []; let total
|
const candidates = []; let total
|
||||||
@@ -278,6 +340,21 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
|
|||||||
&& reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中')
|
&& reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中')
|
||||||
await save({ pickingVerified: true }); state.pickingVerified = true
|
await save({ pickingVerified: true }); state.pickingVerified = true
|
||||||
}
|
}
|
||||||
|
const loadingDocuments = `${pdaDispatch}/loading-documents`
|
||||||
|
const slots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
|
||||||
|
for (const slot of slots) {
|
||||||
|
const photo = await command(`loading-photo-${slot}`, 'POST', query(`${loadingDocuments}/${slot}/uploads`, { idempotencyKey: key(`photo-${slot}`) }),
|
||||||
|
null, pdaToken, {}, `${input.batch}-SYNTHETIC_TEST-${slot}.png`)
|
||||||
|
check(photo.slot === slot && /^[1-9]\d*$/.test(id(photo.fileId)), '装车合成照片回执槽位或身份不符')
|
||||||
|
await recorded(`loading-photo-bind-${slot}`, 'PUT', `${loadingDocuments}/${slot}`, async () => {
|
||||||
|
const documents = await read(loadingDocuments, pdaToken)
|
||||||
|
check(documents.dispatchNo === dispatchNo && documents.stage === 'LOADING' && documents.editable === true, '本车装车资料尚不可编辑')
|
||||||
|
return { recordVersion: String(version(documents.recordVersion)), idempotencyKey: key(`photo-bind-${slot}`), fileId: id(photo.fileId) }
|
||||||
|
}, pdaToken)
|
||||||
|
}
|
||||||
|
const documents = await read(loadingDocuments, pdaToken)
|
||||||
|
check(documents.dispatchNo === dispatchNo && documents.complete === true && slots.every(slot =>
|
||||||
|
documents.photos?.filter(photo => photo.slot === slot && id(photo.fileId)).length === 1), '本车五槽装车资料未核齐')
|
||||||
const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => {
|
const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => {
|
||||||
const preview = await read(`${pdaDispatch}/loading`, pdaToken)
|
const preview = await read(`${pdaDispatch}/loading`, pdaToken)
|
||||||
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING'
|
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING'
|
||||||
@@ -302,55 +379,47 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
|
|||||||
&& Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`)
|
&& Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const measurements = controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets)
|
const measurements = syntheticLoading ? null : controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets)
|
||||||
|
check(preview.pallets.every(row => Number.isSafeInteger(Number(row.scheduledPackages)) && Number(row.scheduledPackages) > 0
|
||||||
|
&& Number(row.pickedPackages) === Number(row.scheduledPackages) && Number.isSafeInteger(row.deliveryIndex)
|
||||||
|
&& row.deliveryIndex >= 1 && trip.trip.stops?.[row.deliveryIndex]?.destinationKey), '本票正式预览拣货或卸货路线不符')
|
||||||
const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo,
|
const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo,
|
||||||
source: input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW',
|
source: syntheticLoading ? 'SYNTHETIC_TEST' : input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW',
|
||||||
|
...(syntheticLoading ? { actual: false, realMeasurementVerified: false, tripNo, stops: trip.trip.stops,
|
||||||
|
previewPallets: preview.pallets.map(row => ({ warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, deliveryIndex: row.deliveryIndex })) } : {}),
|
||||||
file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements }
|
file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements }
|
||||||
if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence),
|
if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence),
|
||||||
'已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令')
|
'已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令')
|
||||||
else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence }
|
else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence }
|
||||||
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash,
|
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash,
|
||||||
measurements, remark: `${remark}仅采用真实来源测量,不从计费或计划常量推算` }
|
remark: syntheticLoading ? `${remark}合成验收,仅验证测试HTTP流程,不提供实测数值` : `${remark}仅采用真实来源测量,不从计费或计划常量推算` }
|
||||||
}, pdaToken)
|
}, pdaToken)
|
||||||
check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'AFTER_LOADING'
|
check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'DELIVERING'
|
||||||
&& loaded.result?.confirmedLoad?.pallets?.length === 1, '本票显式装车回执不符')
|
&& loaded.result?.confirmedLoad?.pallets?.length === 1
|
||||||
const transfer = await recorded('transfer', 'POST', `${dispatchPath}/transfer-delivery`, async () => {
|
&& id(loaded.result.confirmedLoad.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票装车自动发车回执不符')
|
||||||
const preview = await read(`${dispatchPath}/delivery-arrangement`)
|
if (!syntheticLoading) {
|
||||||
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
const confirmed = loaded.result.confirmedLoad.pallets[0], measured = state.loadingMeasurementEvidence.measurements[0]
|
||||||
&& preview.arrange?.allowed === true && preview.cargoHash && preview.loadingCompletionHash && preview.groups?.length === 1
|
check(['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].every(field => Number(confirmed[field]) === Number(measured[field])),
|
||||||
&& id(preview.groups[0].customerId) === input.customerId && preview.groups[0].cargo?.length > 0
|
'服务端已确认装车与本轮实测核验不一致')
|
||||||
&& preview.groups[0].cargo.every(row => id(row.customerId) === input.customerId && row.inboundCode === domestic.inboundCode)
|
}
|
||||||
&& preview.groups[0].blockReasons?.length === 0 && preview.groups[0].externalQuotes?.length === 0, '本票派送安排不完整、混入其他票或存在外接约定')
|
const { detail: dispatched } = await ownMain()
|
||||||
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('transfer'), cargoHash: preview.cargoHash,
|
const documentsAfter = dispatched.documents
|
||||||
loadingCompletionHash: preview.loadingCompletionHash, groups: preview.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
|
check(dispatched.stage === 'DELIVERING' && Number(dispatched.counts?.shippedPackageCount) === input.packageCount
|
||||||
plannedDeliveryAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'),
|
&& documentsAfter?.dispatchNo === dispatchNo && documentsAfter.deliveries?.length === 1 && documentsAfter.outbounds?.length === 1
|
||||||
carrierName: input.resources.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本票真实实载',
|
&& id(documentsAfter.deliveries[0].customerId) === input.customerId && id(documentsAfter.outbounds[0].customerId) === input.customerId
|
||||||
appointmentAccount: 'SELF', sellerName: null, remark, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash,
|
&& documentsAfter.deliveries[0].outboundNo === documentsAfter.outbounds[0].outboundNo
|
||||||
feeCoverage: [], fees: (group.requiredServices || []).map(feeType => ({ feeType, feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark })) })) }
|
&& Number(documentsAfter.deliveries[0].packageCount) === input.packageCount, '本票自动派送出库身份或数量不符')
|
||||||
})
|
const outboundNo = documentsAfter.outbounds[0].outboundNo, deliveryNo = documentsAfter.deliveries[0].deliveryNo
|
||||||
check(transfer.dispatchNo === dispatchNo && transfer.status === 'ACTIVE' && transfer.stage === 'AFTER_LOADING'
|
|
||||||
&& transfer.result?.deliveries?.length === 1 && transfer.result.outbounds?.length === 1
|
|
||||||
&& id(transfer.result.deliveries[0].customerId) === input.customerId && id(transfer.result.outbounds[0].customerId) === input.customerId
|
|
||||||
&& transfer.result.deliveries[0].outboundNo === transfer.result.outbounds[0].outboundNo
|
|
||||||
&& Number(transfer.result.deliveries[0].packageCount) === input.packageCount, '本票未形成原生唯一客户出库和派送')
|
|
||||||
const outboundNo = transfer.result.outbounds[0].outboundNo, deliveryNo = transfer.result.deliveries[0].deliveryNo
|
|
||||||
const departed = await recorded('depart', 'POST', `${pdaDispatch}/depart`, async () => {
|
|
||||||
const ready = await read(pdaDispatch, pdaToken), { detail } = await ownMain()
|
|
||||||
check(ready.dispatchNo === dispatchNo && ready.status === 'ACTIVE' && ready.stage === 'AFTER_LOADING'
|
|
||||||
&& ready.pickingReconciled === true && ready.actions?.DEPART?.allowed === true && detail.actions?.DEPART?.allowed === true
|
|
||||||
&& id(ready.recordVersion) === id(detail.recordVersion), 'PDA及平台当前主版本发车门禁未通过')
|
|
||||||
return { recordVersion: version(ready.recordVersion), idempotencyKey: key('depart'), remark }
|
|
||||||
}, pdaToken)
|
|
||||||
check(departed.dispatchNo === dispatchNo && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING'
|
|
||||||
&& Number(departed.result?.shippedPackages) === input.packageCount && departed.result.deliveries?.length === 1
|
|
||||||
&& departed.result.deliveries[0].deliveryNo === deliveryNo, '本票原生发车结果不符')
|
|
||||||
const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId }))
|
const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId }))
|
||||||
check(id(outbound.outbound?.customerId) === input.customerId && outbound.outbound.dispatchNo === dispatchNo
|
check(outbound.outbound?.outboundNo === outboundNo && outbound.outbound.dispatchNo === dispatchNo
|
||||||
|
&& id(outbound.outbound.overseasWarehouseId) === input.warehouseId && outbound.deliveries?.length === 1
|
||||||
|
&& id(outbound.deliveries[0].customerId) === input.customerId && outbound.deliveries[0].deliveryNo === deliveryNo
|
||||||
&& outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
|
&& outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
|
||||||
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库')
|
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库')
|
||||||
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
|
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
|
||||||
tailOrderId, dispatchNo, outboundNo, deliveryNo,
|
tailOrderId, dispatchNo, outboundNo, deliveryNo,
|
||||||
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' }
|
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY',
|
||||||
|
loadingMeasurementMode: syntheticLoading ? 'SYNTHETIC_TEST' : 'ACTUAL_EVIDENCE', realMeasurementVerified: !syntheticLoading }
|
||||||
await save({ result }); return result
|
await save({ result }); return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { loadingVerificationResult } from './prod-release-full-flow.mjs'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises'
|
import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
|
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip, validateSyntheticLoadingInput } from './prod-release-overseas-stage.mjs'
|
||||||
|
|
||||||
const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
|
const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
|
||||||
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
|
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
|
||||||
@@ -62,12 +63,16 @@ test('current receiving probe preserves auth, transport and other business error
|
|||||||
})
|
})
|
||||||
|
|
||||||
function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false,
|
function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false,
|
||||||
foreignTrip = false, candidateComplete = true, loseCommand = null } = {}) {
|
foreignTrip = false, candidateComplete = true, loseCommand = null, missingAppointment = false } = {}) {
|
||||||
const checkpoint = journal(), calls = []
|
const checkpoint = journal(), calls = []
|
||||||
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
|
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
|
||||||
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
|
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
|
||||||
const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}`
|
const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}`
|
||||||
let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0
|
let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0, documentVersion = 0
|
||||||
|
const loadingPhotos = []
|
||||||
|
let savedMaintenance = { fleetEmail: 'synthetic@example.invalid', destinationAppointments: [] }, savedVehicle = { mode: 'MASTER', vehicleId: '3' }, savedDriver = { mode: 'MASTER', driverId: '4' }
|
||||||
|
let plannedDepartureAt = null, estimatedArrivalAt = null
|
||||||
|
const appointments = () => missingAppointment ? [{ destinationKey: 'FBA:DEST', destinationWarehouseCode: 'DEST', appointmentNo: savedMaintenance.destinationAppointments[0]?.appointmentNo ?? null, status: savedMaintenance.destinationAppointments.length ? 'REGISTERED' : 'PENDING' }] : []
|
||||||
const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }]
|
const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }]
|
||||||
const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 })
|
const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 })
|
||||||
const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result })
|
const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result })
|
||||||
@@ -87,7 +92,11 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
|
|||||||
value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) })
|
value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) })
|
||||||
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
|
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
|
||||||
requestWithBody: async (method, path, form, token) => {
|
requestWithBody: async (method, path, form, token) => {
|
||||||
calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' })
|
calls.push({ method, path, token }); assert.ok(form instanceof FormData)
|
||||||
|
if (path.includes('/evidence/')) { assert.equal(token, 'admin'); return ok({ fileId: '10' }) }
|
||||||
|
assert.equal(token, 'pda')
|
||||||
|
if (path.includes('/loading-documents/')) return ok({ slot: path.split('/loading-documents/')[1].split('/')[0], fileId: '10' })
|
||||||
|
return ok({ fileId: '10' })
|
||||||
}, request: async (method, path, body, token) => {
|
}, request: async (method, path, body, token) => {
|
||||||
calls.push({ method, path, body: body == null ? body : structuredClone(body), token })
|
calls.push({ method, path, body: body == null ? body : structuredClone(body), token })
|
||||||
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
|
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
|
||||||
@@ -110,12 +119,26 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
|
|||||||
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) }
|
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) }
|
||||||
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 })
|
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 })
|
||||||
if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip())
|
if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip())
|
||||||
|
if (path === dispatchPath && method === 'PUT') {
|
||||||
|
assert.equal(body.recordVersion, recordVersion); recordVersion++
|
||||||
|
savedVehicle = structuredClone(body.vehicle); savedDriver = structuredClone(body.driver)
|
||||||
|
plannedDepartureAt = body.plannedDepartureAt; estimatedArrivalAt = body.estimatedArrivalAt
|
||||||
|
if (body.maintenance) savedMaintenance = structuredClone(body.maintenance)
|
||||||
|
if (body.idempotencyKey.endsWith('-destination-appointment') && loseCommand === 'appointment') throw Error('appointment response lost')
|
||||||
|
return ok(nativeResult(null))
|
||||||
|
}
|
||||||
if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
|
if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
|
||||||
|
documents: { dispatchNo, outbounds: stage === 'DELIVERING' ? [{ customerId: '1', outboundNo: 'OUT-TEST' }] : [], deliveries: stage === 'DELIVERING' ? [delivery] : [] },
|
||||||
orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }],
|
orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }],
|
||||||
vehicleInfo: { vehicle: { vehicleId: '3' }, driver: { driverId: '4' } }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
|
vehicleInfo: { vehicle: savedVehicle, driver: savedDriver, maintenance: savedMaintenance },
|
||||||
|
dispatchInfo: { fbaAppointments: appointments(), plannedDepartureAt, estimatedArrivalAt }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
|
||||||
if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] })
|
if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] })
|
||||||
if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1,
|
if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1,
|
||||||
items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
|
items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
|
||||||
|
if (path.startsWith(`${pdaPath}/loading-documents`)) {
|
||||||
|
if (method === 'PUT') { assert.equal(body.recordVersion, String(documentVersion)); documentVersion++; loadingPhotos.push({ slot: path.split('/').at(-1), fileId: body.fileId }) }
|
||||||
|
return ok({ dispatchNo, stage, recordVersion: String(documentVersion), dispatchRecordVersion: String(recordVersion), editable: true, complete: loadingPhotos.length === 5, photos: [...loadingPhotos] })
|
||||||
|
}
|
||||||
if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
|
if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
|
||||||
loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] })
|
loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] })
|
||||||
if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true },
|
if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true },
|
||||||
@@ -125,7 +148,10 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
|
|||||||
recordVersion++
|
recordVersion++
|
||||||
if (path.endsWith('/loading-review')) stage = 'LOADING'
|
if (path.endsWith('/loading-review')) stage = 'LOADING'
|
||||||
if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 }
|
if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 }
|
||||||
if (path.endsWith('/loading-complete')) { stage = 'AFTER_LOADING'; return ok(nativeResult({ confirmedLoad: { pallets: body.measurements } })) }
|
if (path.endsWith('/loading-complete')) {
|
||||||
|
assert.deepEqual(Object.keys(body).sort(), ['idempotencyKey','manifestHash','recordVersion','remark'])
|
||||||
|
assert.equal(loadingPhotos.length, 5); stage = 'DELIVERING'; return ok(nativeResult({ confirmedLoad: { pallets: [{ ...measured(), palletSpaces: 2 }] } }))
|
||||||
|
}
|
||||||
if (path.endsWith('/transfer-delivery')) {
|
if (path.endsWith('/transfer-delivery')) {
|
||||||
assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1)
|
assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1)
|
||||||
assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10)
|
assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10)
|
||||||
@@ -135,7 +161,7 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
|
|||||||
if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss')
|
if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss')
|
||||||
return ok(nativeResult(null))
|
return ok(nativeResult(null))
|
||||||
}
|
}
|
||||||
if (path.includes('/outbound-orders/')) return ok({ outbound: { customerId: '1', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ inboundCode: domestic.inboundCode }] })
|
if (path.includes('/outbound-orders/')) return ok({ outbound: { outboundNo: 'OUT-TEST', overseasWarehouseId: '2', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, deliveries: [delivery], pallets: [{ inboundCode: domestic.inboundCode }] })
|
||||||
if (method === 'GET') assert.fail(`unexpected GET ${path}`)
|
if (method === 'GET') assert.fail(`unexpected GET ${path}`)
|
||||||
return ok({})
|
return ok({})
|
||||||
} }
|
} }
|
||||||
@@ -148,7 +174,8 @@ test('normal single-ticket HTTP chain reaches outbound with existing master reso
|
|||||||
assert.equal(result.fullFlowVerified, false)
|
assert.equal(result.fullFlowVerified, false)
|
||||||
assert.equal(result.deliveryNo, 'DELIVERY-TEST')
|
assert.equal(result.deliveryNo, 'DELIVERY-TEST')
|
||||||
assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda'))
|
assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda'))
|
||||||
assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda'))
|
assert.ok(f.calls.some(row => row.path.endsWith('/loading-complete') && row.token === 'pda'))
|
||||||
|
assert.equal(f.calls.filter(row => /delivery-arrangement|transfer-delivery|\/depart$/.test(row.path)).length, 0)
|
||||||
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1)
|
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1)
|
||||||
assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1)
|
assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1)
|
||||||
assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
|
assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
|
||||||
@@ -208,7 +235,7 @@ test('native successful continuation restores original dynamic requests without
|
|||||||
assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo)
|
assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo)
|
||||||
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1)
|
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1)
|
||||||
assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit)
|
assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit)
|
||||||
assert.equal(firstSubmit.body.recordVersion, 0)
|
assert.equal(firstSubmit.body.recordVersion, 1)
|
||||||
assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1)
|
assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -252,14 +279,16 @@ test('incomplete candidates, foreign cargo and wrong auto-trip binding cannot en
|
|||||||
test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => {
|
test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => {
|
||||||
const f = fixture(); await runOverseasStage(f)
|
const f = fixture(); await runOverseasStage(f)
|
||||||
const load = f.calls.find(row => row.path.endsWith('/loading-complete'))
|
const load = f.calls.find(row => row.path.endsWith('/loading-complete'))
|
||||||
assert.deepEqual(load.body.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }])
|
assert.equal(Object.hasOwn(load.body, 'measurements'), false)
|
||||||
|
assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }])
|
||||||
const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches')
|
const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches')
|
||||||
assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' })
|
assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' })
|
||||||
assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4')
|
assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4')
|
||||||
assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt)
|
assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt)
|
||||||
assert.equal(Object.hasOwn(create.body, 'customerId'), false)
|
assert.equal(Object.hasOwn(create.body, 'customerId'), false)
|
||||||
const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load), arrangement = f.calls.findIndex(row => row.path.endsWith('/transfer-delivery')), depart = f.calls.findIndex(row => row.path.endsWith('/depart'))
|
const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load)
|
||||||
assert.ok(pick < loading && loading < arrangement && arrangement < depart)
|
assert.ok(pick < loading)
|
||||||
|
assert.equal(f.calls.filter(row => /delivery-arrangement|transfer-delivery|\/depart$/.test(row.path)).length, 0)
|
||||||
assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0)
|
assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0)
|
||||||
assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0)
|
assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0)
|
||||||
})
|
})
|
||||||
@@ -270,7 +299,7 @@ test('lost native review result preserves exact INTENT request and prevents all
|
|||||||
await assert.rejects(runOverseasStage(f), /response loss/)
|
await assert.rejects(runOverseasStage(f), /response loss/)
|
||||||
const intent = f.checkpoint.state.commands['dispatch-review']
|
const intent = f.checkpoint.state.commands['dispatch-review']
|
||||||
assert.equal(intent.status, 'INTENT')
|
assert.equal(intent.status, 'INTENT')
|
||||||
assert.equal(intent.request.body.recordVersion, 1)
|
assert.equal(intent.request.body.recordVersion, 2)
|
||||||
assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`)
|
assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`)
|
||||||
const calls = f.calls.length
|
const calls = f.calls.length
|
||||||
await assert.rejects(runOverseasStage(f), /结果未知/)
|
await assert.rejects(runOverseasStage(f), /结果未知/)
|
||||||
@@ -291,6 +320,28 @@ function measurementReceipt(patch = {}) {
|
|||||||
sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch }
|
sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('gcp-test loading accepts only GCP-TEST receipt and preserves actual field evidence', async () => {
|
||||||
|
for (const environment of ['TEST', 'GRAY', 'gcp-test', 'GCP-TEST']) {
|
||||||
|
const f = fixture({ missingActual: true })
|
||||||
|
f.input = { ...f.input, environment: { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' },
|
||||||
|
loadingMeasurements: measurementReceipt({ environment }) }
|
||||||
|
if (environment === 'GCP-TEST') {
|
||||||
|
assert.equal((await runOverseasStage(f)).status, 'OVERSEAS_OUTBOUNDED')
|
||||||
|
assert.equal(f.checkpoint.state.loadingMeasurementEvidence.environment, 'GCP-TEST')
|
||||||
|
assert.equal(f.checkpoint.state.loadingMeasurementEvidence.receipt.environment, 'GCP-TEST')
|
||||||
|
} else {
|
||||||
|
await assert.rejects(runOverseasStage(f), /绑定不符/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f = fixture({ missingActual: true })
|
||||||
|
const receipt = measurementReceipt({ environment: 'GCP-TEST' })
|
||||||
|
receipt.pallets[0].evidence[0].actual = false
|
||||||
|
f.input = { ...f.input, environment: { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' }, loadingMeasurements: receipt }
|
||||||
|
await assert.rejects(runOverseasStage(f), /actual来源证据/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => {
|
test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-'))
|
const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-'))
|
||||||
try {
|
try {
|
||||||
@@ -364,3 +415,119 @@ test('changed file after an unknown loading write cannot replace frozen original
|
|||||||
assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original)
|
assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original)
|
||||||
} finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) }
|
} finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('synthetic loading is explicit GCP-only and rejects ambiguous actual inputs before HTTP', () => {
|
||||||
|
const base = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
|
||||||
|
validateSyntheticLoadingInput(base)
|
||||||
|
for (const patch of [{ environment: { mode: 'test' } }, { environment: { mode: 'gray' } }, { environment: { mode: 'prod' } },
|
||||||
|
{ syntheticOnly: false }, { syntheticLoading: 'true' }, { loadingMeasurements: {} }, { loadingMeasurementFile: 'anything' },
|
||||||
|
{ syntheticLoadingMeasurements: { values: { palletSpaces: 1, weightKg: 1, volumeCbm: 1 } } }]) {
|
||||||
|
assert.throws(() => validateSyntheticLoadingInput({ ...base, ...patch }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit GCP synthetic path never manufactures actual evidence or sends numeric measurements', async () => {
|
||||||
|
const f = fixture({ missingActual: true })
|
||||||
|
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
|
||||||
|
const result = await runOverseasStage(f)
|
||||||
|
assert.equal(result.realMeasurementVerified, false)
|
||||||
|
assert.equal(result.loadingMeasurementMode, 'SYNTHETIC_TEST')
|
||||||
|
assert.deepEqual(loadingVerificationResult(result), { loadingMeasurementMode: 'SYNTHETIC_TEST', realMeasurementVerified: false, scope: 'ONE_ORDER_SYNTHETIC_HTTP_PATH' })
|
||||||
|
const proof = f.checkpoint.state.loadingMeasurementEvidence
|
||||||
|
assert.equal(proof.actual, false); assert.equal(proof.source, 'SYNTHETIC_TEST'); assert.equal(proof.measurements, null)
|
||||||
|
assert.equal(proof.dispatchNo, 'DISPATCH-TEST'); assert.equal(proof.tripNo, 'TRIP-TEST')
|
||||||
|
assert.equal(JSON.stringify(proof).includes('"actual":true'), false)
|
||||||
|
const load = f.checkpoint.state.commands['loading-complete'].request
|
||||||
|
assert.equal(Object.hasOwn(load.body, 'measurements'), false)
|
||||||
|
assert.match(load.body.remark, /SYNTHETIC_TEST:actual=false/)
|
||||||
|
const writes = f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length
|
||||||
|
await runOverseasStage(f)
|
||||||
|
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes)
|
||||||
|
const calls = f.calls.length
|
||||||
|
f.input = { ...f.input, syntheticLoading: false }
|
||||||
|
await assert.rejects(runOverseasStage(f), /冻结检查点/)
|
||||||
|
assert.equal(f.calls.length, calls)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('synthetic unknown loading result blocks all continuation, including reads', async () => {
|
||||||
|
const f = fixture({ missingActual: true })
|
||||||
|
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
|
||||||
|
const request = f.context.request
|
||||||
|
f.context.request = async (...args) => { const result = await request(...args); if (args[1].endsWith('/loading-complete')) throw Error('response lost'); return result }
|
||||||
|
await assert.rejects(runOverseasStage(f), /response lost/)
|
||||||
|
const calls = f.calls.length
|
||||||
|
assert.equal(f.checkpoint.state.commands['loading-complete'].status, 'INTENT')
|
||||||
|
await assert.rejects(runOverseasStage(f), /结果未知/)
|
||||||
|
assert.equal(f.calls.length, calls)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('synthetic mode preserves authoritative preview pallet, dispatch, pick and stop boundaries', async () => {
|
||||||
|
for (const mutate of [p => { p.dispatchNo = 'FOREIGN' }, p => { p.pallets.push({ ...p.pallets[0] }) },
|
||||||
|
p => { p.pallets[0].warehousePalletId = '999' }, p => { p.pallets[0].pickedPackages = 0 },
|
||||||
|
p => { p.pallets[0].deliveryIndex = 99 }]) {
|
||||||
|
const f = fixture({ missingActual: true }), request = f.context.request
|
||||||
|
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
|
||||||
|
f.context.request = async (...args) => { const result = await request(...args); if (args[0] === 'GET' && args[1].endsWith('/loading')) mutate(result.body.data); return result }
|
||||||
|
await assert.rejects(runOverseasStage(f), /预览|路线/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uncertain synthetic photo upload is not resent and incomplete slots cannot trigger auto departure', async () => {
|
||||||
|
const f = fixture(), upload = f.context.requestWithBody
|
||||||
|
f.context.requestWithBody = async (...args) => { const result = await upload(...args); if (args[1].includes('/loading-documents/')) throw Error('photo response lost'); return result }
|
||||||
|
await assert.rejects(runOverseasStage(f), /photo response lost/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['loading-photo-LOADING_SCENE'].status, 'INTENT')
|
||||||
|
const calls = f.calls.length
|
||||||
|
await assert.rejects(runOverseasStage(f), /结果未知/)
|
||||||
|
assert.equal(f.calls.length, calls)
|
||||||
|
const other = fixture(), request = other.context.request
|
||||||
|
other.context.request = async (...args) => { const result = await request(...args); if (args[0] === 'GET' && args[1].endsWith('/loading-documents')) result.body.data.complete = false; return result }
|
||||||
|
await assert.rejects(runOverseasStage(other), /五槽装车资料未核齐/)
|
||||||
|
assert.equal(other.checkpoint.state.commands['loading-complete'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('GCP synthetic FBA appointment binds only this destination and preserves photos, maintenance and schedule', async () => {
|
||||||
|
const f = fixture({ missingAppointment: true })
|
||||||
|
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'DEST' } }
|
||||||
|
await runOverseasStage(f)
|
||||||
|
const resource = f.checkpoint.state.commands['resource-evidence'].request.body
|
||||||
|
const appointment = f.checkpoint.state.commands['destination-appointment'].request.body
|
||||||
|
assert.deepEqual(appointment.vehicle.evidenceFileIds, resource.vehicle.evidenceFileIds)
|
||||||
|
assert.deepEqual(appointment.driver.evidence, resource.driver.evidence)
|
||||||
|
assert.equal(appointment.plannedDepartureAt, resource.plannedDepartureAt)
|
||||||
|
assert.equal(appointment.estimatedArrivalAt, resource.estimatedArrivalAt)
|
||||||
|
assert.equal(appointment.maintenance.fleetEmail, 'synthetic@example.invalid')
|
||||||
|
assert.deepEqual(appointment.maintenance.destinationAppointments, [{ destinationKey: 'FBA:DEST', appointmentNo: `SYNTHETIC_TEST-${input.batch}-DEST` }])
|
||||||
|
assert.equal(f.checkpoint.state.destinationAppointmentEvidence.actual, false)
|
||||||
|
assert.match(appointment.remark, /不代表第三方确认/)
|
||||||
|
const submit = f.checkpoint.state.commands['submit-review'].request.body
|
||||||
|
assert.equal(submit.recordVersion, appointment.recordVersion + 1)
|
||||||
|
assert.equal(submit.idempotencyKey, `${input.batch}-submit-review-with-appointment`)
|
||||||
|
const writes = f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length
|
||||||
|
await runOverseasStage(f)
|
||||||
|
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing FBA appointment never gets fabricated outside explicit GCP mode or for a foreign destination', async () => {
|
||||||
|
for (const patch of [{}, { environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'OTHER' } }]) {
|
||||||
|
const f = fixture({ missingAppointment: true }); f.input = { ...input, ...patch }
|
||||||
|
await assert.rejects(runOverseasStage(f), /预约|FBA目的仓/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['destination-appointment'], undefined)
|
||||||
|
assert.equal(f.checkpoint.state.commands['submit-review'], undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uncertain appointment update preserves INTENT and cannot be resent or followed by submit review', async () => {
|
||||||
|
const f = fixture({ missingAppointment: true, loseCommand: 'appointment' })
|
||||||
|
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'DEST' } }
|
||||||
|
await assert.rejects(runOverseasStage(f), /appointment response lost/)
|
||||||
|
assert.equal(f.checkpoint.state.commands['destination-appointment'].status, 'INTENT')
|
||||||
|
assert.equal(f.checkpoint.state.commands['submit-review'], undefined)
|
||||||
|
const calls = f.calls.length
|
||||||
|
await assert.rejects(runOverseasStage(f), /结果未知/)
|
||||||
|
assert.equal(f.calls.length, calls)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user