This commit is contained in:
2026-10-21 15:53:33 +08:00
parent 94595c810b
commit 090591076f
41 changed files with 459 additions and 63 deletions
@@ -77,6 +77,12 @@ public class DispatchAggregateController {
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 派送异常登记不触发签收或退货入库。 */
@PostMapping("/{dispatchNo}/exceptions")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
@@ -24,6 +24,7 @@ public class DispatchAggregate {
private String dispatchReviewJson;
private String loadingReviewJson;
private String resourcesJson;
private String exceptionsJson;
private String vehiclePlate;
private String route;
private LocalDateTime plannedDepartureAt;
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO }
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
@@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
private static boolean supportedType(Kind kind,String contentType) {
return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType));
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType));
}
/** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
@@ -94,7 +94,7 @@ public interface DispatchAggregateMapper {
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
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},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
@@ -73,6 +73,13 @@ public final class DispatchCommands {
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
/** 必填版本用于阻止过期页面覆盖当前排车。 */
public MarkException { recordVersion = requireVersion(recordVersion); }
}
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
/** 实际发车不得接受缺省版本。 */
public Depart { recordVersion = requireVersion(recordVersion); }
@@ -9,6 +9,8 @@ public final class DispatchTypes {
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION }
public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER }
public enum Decision { APPROVED, REJECTED }
public enum ResourceMode { MASTER, EXTERNAL }
public enum ActorMode { PLATFORM, PDA }
@@ -18,6 +20,6 @@ public final class DispatchTypes {
public enum Effect { APPLIED, EXISTING }
public enum Action {
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION
}
}
@@ -44,7 +44,11 @@ public final class DispatchViews {
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
List<BatchReference> appointmentBatches) { }
List<BatchReference> appointmentBatches, List<ExceptionRecord> exceptions) { }
/** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */
public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection,
String description, List<Long> imageFileIds, Instant occurredAt) { }
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
List<DispatchDeliveryModels.DeliveryReference> deliveries,
@@ -238,7 +238,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
requirePlatform(actor);var row=required(actor,number,false);
if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) {
requireExceptionAllowed(actor,row,row.getRecordVersion());
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
requireEditing(row,row.getRecordVersion());
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
@@ -663,6 +668,40 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var id:ids)evidence.metadata(dispatch,kind,id);
}
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireExceptionAllowed(actor,row,request.recordVersion());
if(request.exceptionType()==null)throw bad("请选择异常类型");
var description=trim(request.description());
if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字");
var ids=request.imageFileIds()==null?List.<Long>of():request.imageFileIds();
if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张");
// 文件须属于本车异常类别,不能借用其他车辆或审核附件。
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
var records=new ArrayList<>(exceptionRecords(row));
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
row.setExceptionsJson(write(records));
return saveCommand(row,actor,identity,description);
}
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常");
if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常");
}
/** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */
private List<DispatchViews.ExceptionRecord> exceptionRecords(DispatchAggregate row) {
if(row.getExceptionsJson()==null)return List.of();
return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class));
}
/** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
@@ -699,7 +738,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo));
}
@@ -817,6 +856,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var action:Action.values()) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
boolean state=switch(action) {
case MARK_EXCEPTION -> active && stage==Stage.DELIVERING;
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
@@ -73,7 +73,7 @@ public final class DispatchCommandCodec {
case DEPART -> DispatchResults.Departed.class;
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class;
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class;
};
}
@@ -7,5 +7,6 @@ public record ContainerArrivalTimeView(
String sourceContainerBookingId,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt,
int version
int version,
long planCount
) { }
@@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
"AND (MAX(o.arrival_time_version)&gt;0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version " +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
"FROM ll_order o WHERE o.source_container_booking_id IN " +
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY o.source_container_booking_id</script>")
@@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
@@ -1682,6 +1682,49 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions:
post:
summary: 登记派送异常
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchMarkException'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
@@ -1880,7 +1923,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
post:
summary: 上传本趟车辆或司机证据
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO和EXCEPTION仅JPG/PNG;EXCEPTION仅派送中使用排车edit权限,引用随异常登记保存。非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
security:
- bearerAuth: []
responses:
@@ -1935,6 +1978,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
get:
summary: 读取本车证据元数据
@@ -1981,6 +2025,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
@@ -2024,6 +2069,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
@@ -3503,12 +3549,13 @@ components:
remark: { type: string, maxLength: 500, nullable: true }
ContainerArrivalTimeView:
type: object
required: [sourceContainerBookingId, version]
required: [sourceContainerBookingId, version, planCount]
properties:
sourceContainerBookingId: { type: string }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 }
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
version: { type: integer, minimum: 0 }
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
@@ -5142,6 +5189,7 @@ components:
- LOADING_COMPLETE
- ARRANGE_DELIVERY
- DEPART
- MARK_EXCEPTION
DispatchGate:
type: object
x-java-record: DispatchContracts.Gate
@@ -6314,6 +6362,10 @@ components:
items:
$ref: '#/components/schemas/DispatchBatchReference'
nullable: true
exceptions:
type: array
items:
$ref: '#/components/schemas/DispatchExceptionRecord'
required:
- route
- plannedDepartureAt
@@ -6321,6 +6373,7 @@ components:
- estimatedArrivalAt
- fbaAppointments
- appointmentBatches
- exceptions
DispatchDocuments:
type: object
x-java-record: DispatchViews.Documents
@@ -7940,5 +7993,66 @@ components:
- sourceInboundOrderNo
- sourceInboundCode
- productQuantity
DispatchExceptionType:
type: string
enum: [REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION]
description: 拒收、部分签收、车辆异常。
DispatchReturnDirection:
type: string
enum: [OVERSEAS_WAREHOUSE, OTHER]
description: 退回海外仓、其他;仅登记去向,不自动退货入库。
DispatchMarkException:
type: object
x-java-record: DispatchCommands.MarkException
required: [recordVersion, idempotencyKey, exceptionType]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey:
type: string
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
maxLength: 1000
nullable: true
imageFileIds:
type: array
maxItems: 20
uniqueItems: true
items:
type: string
pattern: ^[1-9][0-9]*$
DispatchExceptionRecord:
type: object
x-java-record: DispatchViews.ExceptionRecord
required: [exceptionType, returnDirection, description, imageFileIds, occurredAt]
properties:
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
nullable: true
imageFileIds:
type: array
items:
type: string
occurredAt:
type: string
format: date-time
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
@@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest {
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
config.addMapper(LastlegOrderMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size());
for(var calendar:rows) {
var arrival=customs.stream().filter(row -> row.sourceContainerBookingId().equals(calendar.sourceContainerBookingId())).findFirst().orElseThrow();
assertEquals(calendar.plannedAt(),arrival.estimatedWarehouseArrivalAt());
assertEquals(calendar.actualAt(),arrival.actualWarehouseArrivalAt());
assertEquals(calendar.version(),arrival.version());
assertEquals(calendar.planCount(),arrival.planCount());
}
}
}
}
@@ -45,6 +45,15 @@ class HttpDispatchEvidenceGatewayTest {
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 异常图片独立归属本车,审核照片及PDF均不能用作异常图片。 */
@Test void exceptionImagesRejectOtherKindsCarsAndPdf() {
var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.png","image/png",128L);
assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L).fileId()).isEqualTo(3L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),10,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
for(var value:java.util.List.of(
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 通过真实Mapper和事务验证异常登记,不把登记事实解释成签收或退货。 */
class DispatchExceptionPersistenceTest {
/** 登记使用排车独立维护权限。 */
@BeforeEach void authorize() { SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:edit")); }
/** 清除线程身份,避免影响其他权限回归。 */
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
/** 每种原型类型均可保存,原键重试不追加,最终完成后仍恢复原结果。 */
@Test void recordsAllTypesAndReplaysWithoutChangingDeliveryFacts() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);long version=0;
var before=f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order");
for(var type:ExceptionType.values()) {
var body=new DispatchCommands.MarkException(version,"exception-"+type,type,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
var saved=f.tx.execute(t->f.service.markException(f.actor,no,body));
var replay=f.tx.execute(t->f.service.markException(f.actor,no,body));
assertThat(saved.stage()).isEqualTo(Stage.DELIVERING);assertThat(replay.replayed()).isTrue();
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());version=saved.recordVersion();
}
var records=f.service.detail(f.actor,no).dispatchInfo().exceptions();assertThat(records).hasSize(3);
assertThat(records).allSatisfy(r->{assertThat(r.description()).isEqualTo("真实异常");assertThat(r.occurredAt()).isNotNull();});
assertThat(f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order")).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='MARK_EXCEPTION'",Long.class)).isEqualTo(3);
var first=new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'");
assertThat(f.tx.execute(t->f.service.markException(f.actor,no,first)).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,null,"改了内容",List.of())))).isInstanceOf(RuntimeException.class);
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).hasSize(3);
}
/** 非派送阶段、过期版本、只读身份、PDA及运输任务不能登记。 */
@Test void rejectsWrongStageVersionPermissionAndTask() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var body=request(0,"blocked",List.of());
for(var stage:List.of("BEFORE_LOADING","LOADING","AFTER_LOADING","DELIVERED")) {
f.db.update("UPDATE ll_dispatch_order SET stage=?",stage);
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
}
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(9,"stale",List.of())))).hasMessageContaining("版本");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("权限");
authorize();f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',status='CANCELLED'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(new DispatchContracts.Actor("pda",ActorMode.PDA,"9"),no,body))).hasMessageContaining("仅平台");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
}
/** 图片严格核对当前车和类别;附件失败或缺类型不保存任何业务记录。 */
@Test void verifiesImageOwnershipAndKeepsFailedRequestUnchanged() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var id=f.repository.find(no).getId();
var files=mock(DispatchEvidenceGateway.class);ReflectionTestUtils.setField(f.service,"evidence",files);
when(files.metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L)).thenThrow(new IllegalArgumentException("文件不属于当前车"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))))).hasMessageContaining("不属于");
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).isEmpty();
var missingType=new DispatchCommands.MarkException(0L,"no-type",null,null,null,List.of());
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,missingType))).hasMessageContaining("请选择异常类型");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"duplicates",List.of(91L,91L))))).hasMessageContaining("图片引用");
doReturn(new DispatchEvidenceGateway.File(91L,"现场.png","image/png",10L)).when(files).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))));
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions().get(0).imageFileIds()).containsExactly(91L);
verify(files,times(2)).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
}
/** 创建真实排车后仅以SQL模拟已发车前置,不声称此用例执行装车流程。 */
private String create(DispatchAggregatePersistenceTest.Fixture f) {
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("create",List.of())));
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=3,departed_at=CURRENT_TIMESTAMP");return created.dispatchNo();
}
/** 构造类型必填、其余选填的最小登记请求。 */
private DispatchCommands.MarkException request(long version,String key,List<Long> images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); }
}
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1)));
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0)));
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");