Compare commits

...
17 changed files with 540 additions and 22 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
exit 1
fi
fi
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV"
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2
fi
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
fi
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
}
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
}
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
/** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
workflow.lockForArrangement(actor,number);var row=required(number);
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
}
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow();
var members=facts.cargo(row.getId()).stream().filter(c->Objects.equals(c.item().getCustomerId(),cargo.item().getCustomerId()) && Objects.equals(c.orderNo(),cargo.orderNo())).toList();
var memberIds=members.stream().map(c->c.item().getId().toString()).collect(java.util.stream.Collectors.toSet());
var current=detail(row);var item=current.items().stream().filter(i->memberIds.contains(i.itemId())).findFirst().orElseThrow();
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
@@ -77,15 +79,18 @@ public class DispatchLooseboardExecutionService {
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
var old=facts.history(row.getId());
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
boolean arrivalRecorded=members.stream().allMatch(c->old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,c) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()));
if(old.stream().filter(e->!memberIds.contains(e.itemId()) || !Objects.equals(e.customerId(),cargo.item().getCustomerId()) || !Objects.equals(e.orderNo(),cargo.orderNo()) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
// 一次订单操作保留逐托来源证据,原送达量统计和结案校验继续按明细精确核对。
for(var member:members){
var evidence=new Evidence(member.item().getId().toString(),member.item().getCustomerId(),member.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
}
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
@@ -171,7 +176,15 @@ public class DispatchLooseboardExecutionService {
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed);
var groups=new LinkedHashMap<List<String>,List<Item>>();
for(var item:items)groups.computeIfAbsent(List.of(item.customerId(),item.orderNo()),key->new ArrayList<>()).add(item);
var orders=groups.values().stream().map(group->{
var first=group.get(0);
return new Item(first.itemId(),first.customerId(),first.orderNo(),first.destination(),group.stream().mapToInt(Item::packages).sum(),
group.stream().flatMap(i->i.pickupPhotos().stream()).distinct().toList(),group.stream().flatMap(i->i.arrivalPhotos().stream()).distinct().toList(),
group.stream().allMatch(Item::canPickup),group.stream().allMatch(Item::canArrive),group.stream().map(Item::warehouseNotice).filter(Objects::nonNull).findFirst().orElse(null),group.stream().anyMatch(Item::pickupRequired));
}).toList();
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,orders,ready,confirmed);
}
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
@Service
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
private final LooseboardCargoRepository cargo;
private final DispatchAggregateRepository dispatches;
private final DispatchAggregateService service;
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
private final DispatchCommandHasher hashes;
/** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service,DispatchCommandHasher hashes) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;this.hashes=hashes;
}
private String warehouse(String number) {
var row=dispatches.find(number);
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
return row.getOverseasWarehouseId();
}
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
@Transactional(readOnly=true)
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL"));
var sources=new ArrayList<Candidate>();
for(int sourcePage=1;;sourcePage++){
var result=service.candidates(actor,new CandidateQuery(warehouse,plans,null,null,null,null,sourcePage,50,"ALL"));
sources.addAll(result.candidates());
if(sourcePage>=result.pages() || result.candidates().isEmpty())break;
}
var groups=new LinkedHashMap<List<String>,List<Candidate>>();
for(var source:sources)groups.computeIfAbsent(List.of(source.customerId(),Objects.requireNonNullElse(source.orderNo(),source.sourceKey())),key->new ArrayList<>()).add(source);
var orders=groups.values().stream().map(this::orderCandidate).toList();
long offset=(long)(page-1)*size;
return new CandidateList(orders.stream().skip(offset).limit(size).toList(),Instant.now(),orders.size()<=size,orders.size(),page,size,(orders.size()+size-1L)/size);
}
/** 合并真实来源闭包和版本,显示箱数按本单来源相加,提交仍逐来源复验。 */
private Candidate orderCandidate(List<Candidate> rows){
var first=rows.get(0);var members=new LinkedHashMap<String,SelectionGroupSource>();
var pallets=new LinkedHashMap<String,SelectionGroupPallet>();var reasons=new LinkedHashSet<String>();boolean complete=true;
for(var row:rows){
reasons.addAll(row.blockReasons());var group=row.selectionGroup();
if(group==null){complete=false;reasons.add("订单货物来源尚未完整,请重新读取");continue;}
complete&=group.complete();reasons.addAll(group.blockReasons());
for(var member:group.sources())members.putIfAbsent(member.sourceKey(),member);
for(var pallet:group.pallets())pallets.putIfAbsent(pallet.warehousePalletId()==null?group.groupKey()+":"+pallet.palletNo():pallet.warehousePalletId().toString(),pallet);
}
var group=new SelectionGroup("LOOSEBOARD_ORDER:"+hashes.source(List.of(first.customerId(),Objects.requireNonNullElse(first.orderNo(),first.sourceKey()))),hashes.source(rows.stream().map(Candidate::selectionGroup).toList()),List.copyOf(members.values()),List.copyOf(pallets.values()),complete,List.copyOf(reasons));
return new Candidate(first.selection(),first.sourceKey(),first.sourceVersion(),first.customerId(),first.customerName(),first.warehouseId(),first.warehouseCode(),first.warehouseName(),first.orderNo(),first.inboundCode(),first.planNo(),null,first.containerNo(),first.destination(),rows.stream().mapToLong(Candidate::availablePackages).sum(),null,null,"ORDER_SOURCES",rows.stream().flatMap(r->r.appointmentBatches().stream()).distinct().toList(),List.copyOf(reasons),group);
}
@Transactional
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound();
var trip = trips.findByNo(binding.getTripNo());
// Check token-bound warehouse before reading any customer's transport or historical data.
// 先校验令牌绑定仓,再读取客户运输及历史资料。
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
|| !dispatchNo.equals(evidence.dispatchNo()) || evidence.shippedPackages()<=0) throw notFound();
var deliveryNumbers=new java.util.HashSet<String>();
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound();
)throw notFound();
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
.eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
try {
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
var originalSnapshot = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class);
var departed = originalSnapshot.trip();
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
@@ -71,6 +74,27 @@ public class FleetHandoverPdaQuery {
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
}
/** 用原始逐来源实载证明客户、目的地及箱数,不能仅放宽整车数量相等判断。 */
private static void requireWarehouseCargo(String number,FleetTripDispatch binding,FleetTransitEvidence evidence,FleetTripModels.AuditSnapshot snapshot){
long packages=evidence.deliveries().stream().mapToLong(d->d.cargo().packages()).sum();
if(snapshot.confirmedLoad()==null){
if(!Objects.equals(binding.getPackageCount(),evidence.shippedPackages()) || packages!=evidence.shippedPackages())throw notFound();
return;
}
var expected=new java.util.HashMap<List<String>,Long>();
if(snapshot.confirmedLoad().cargo()==null)throw notFound();
for(var cargo:snapshot.confirmedLoad().cargo())if(number.equals(cargo.dispatchNo())){
if(cargo.customerId()==null || cargo.destinationKey()==null || cargo.warehousePalletId()==null || cargo.packages()<=0)throw notFound();
expected.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
var actual=new java.util.HashMap<List<String>,Long>();
for(var delivery:evidence.deliveries()){
var cargo=delivery.cargo();if(cargo.destinationKey()==null)throw notFound();
actual.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
if(expected.isEmpty() || !expected.equals(actual) || binding.getPackageCount()==null || packages>evidence.shippedPackages() || evidence.shippedPackages()>binding.getPackageCount())throw notFound();
if(snapshot.dispatches()==null || snapshot.dispatches().stream().noneMatch(d->number.equals(d.dispatchNo()) && d.packageCount()==binding.getPackageCount()))throw notFound();
}
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
}
@@ -6,6 +6,15 @@ import java.util.List;
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
@Mapper
public interface TripOperationsQueryMapper {
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
@ConstructorArgs({
@Arg(column = "trip_no", javaType = String.class),
@Arg(column = "status", javaType = String.class),
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
@Arg(column = "snapshot", javaType = String.class),
@Arg(column = "measured", javaType = String.class),
@Arg(column = "transport_facts", javaType = String.class)
})
@Select("""
<script>
SELECT t.trip_no,t.status,t.completed_at,
@@ -27,7 +36,8 @@ public interface TripOperationsQueryMapper {
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
ELSE NULL END AS transport_facts
FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to}
WHERE t.departed_at &gt;= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
AND t.departed_at &lt; #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.reporting;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
@Override
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
throws SQLException {
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
}
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
@Override
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
@Override
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
@Override
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
return toInstant(statement.getObject(column, LocalDateTime.class));
}
/** 将数据库墙钟值还原为 UTC 时刻。 */
private Instant toInstant(LocalDateTime value) {
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -3,6 +3,8 @@ info:
title: 本土散板排车照片履约
version: 1.0.0
description: >-
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
@@ -3,7 +3,7 @@
"info": {
"title": "真实出车与自有外接运营统计",
"version": "1.0.0",
"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是授权补充事实,与车辆档案累计里程和直接成本独立。"
"description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或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是授权补充事实,与车辆档案累计里程和直接成本独立。"
},
"security": [
{
@@ -14,7 +14,7 @@ paths:
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } }
responses: { '200': { description: 统一ApiResponse;data为CandidateList,同客户散板原单一行且按订单分页,total为订单数;availablePackages汇总本车可选的同单各托,selectionGroup包含该单所有来源及原始版本,任一来源阻断则整单不可选,加入时一次展开全部来源 } }
post:
summary: 将已收货散板加入需求排车
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
@@ -19,6 +19,26 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DispatchLooseboardExecutionServiceTest {
/** 同一客户散板订单多托只显示一次,保存的照片逐托原子留证并精确计量。 */
@Test void oneOrderSharesPhotosAcrossPalletsWithoutCrossingCustomers() {
var f=new Fixture(false);var cargo=f.repository.cargo(1);
cargo.add(f.item(104,"A","ZY1"));cargo.add(f.item(105,"C","ZY3"));
when(f.sourceRepo.businessSources(anyList())).thenReturn(List.of(
new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),
new BusinessSource("A","ZY1","LB1",104L,"WAREHOUSE_PICKUP"),
new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP"),
new BusinessSource("C","ZY3","LB1",105L,"WAREHOUSE_PICKUP")));
var initial=f.service.get(f.actor,"PC1");
assertThat(initial.items()).hasSize(3);
assertThat(initial.items().get(0).packages()).isEqualTo(4);
var saved=f.save(101,Phase.PICKUP,1);
assertThat(saved.items().get(0).pickupPhotos()).hasSize(1);
assertThat(saved.items().get(2).pickupPhotos()).isEmpty();
assertThat(f.facts.current(f.row)).extracting(Evidence::itemId).containsExactlyInAnyOrder("101","104");
f.replace(104,Phase.PICKUP,1L,2L);
assertThat(f.service.get(f.actor,"PC1").items().get(0).pickupPhotos()).hasSize(2);
assertThatThrownBy(()->f.save(105,Phase.PICKUP,1)).hasMessageContaining("不能重复使用");
}
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 散板加货按完整订单分页,来源跨页和客户隔离不能丢失。 */
class LooseboardCargoServiceTest {
/** 同单51托跨来源页仍为一行,另一客户同号订单独立分页。 */
@Test void orderPaginationIncludesEveryPalletAndKeepsCustomersSeparate() throws Exception {
var cargo=mock(LooseboardCargoRepository.class);var repository=mock(DispatchAggregateRepository.class);
var workflow=mock(DispatchAggregateService.class);var json=new ObjectMapper();
var service=new LooseboardCargoService(cargo,repository,workflow,new DispatchCommandHasher(json));
var root=new DispatchAggregate();root.setTaskType("DELIVERY");root.setStatus("ACTIVE");root.setPublishLooseboardDemand(true);root.setOverseasWarehouseId("9");
when(repository.find("PC1")).thenReturn(root);when(cargo.readyPlans("9")).thenReturn(List.of(1L));
var sources=new ArrayList<Candidate>();for(int i=1;i<=51;i++)sources.add(candidate(json,i,"A",i==51));
sources.add(candidate(json,52,"B",false));
when(workflow.candidates(any(),any())).thenAnswer(call->{
CandidateQuery query=call.getArgument(1);int offset=(query.page()-1)*50;
return new CandidateList(sources.subList(offset,Math.min(offset+50,sources.size())),Instant.now(),false,52,query.page(),50,2);
});
var actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
var first=service.candidates(actor,"PC1",1,1);
assertThat(first.total()).isEqualTo(2);assertThat(first.pages()).isEqualTo(2);
assertThat(first.candidates()).singleElement().satisfies(order->{
assertThat(order.availablePackages()).isEqualTo(102);
assertThat(order.selectionGroup().sources()).hasSize(51);
assertThat(order.selectionGroup().pallets()).hasSize(51);
assertThat(order.blockReasons()).containsExactly("最后一托被占用");
});
assertThat(service.candidates(actor,"PC1",2,1).candidates()).singleElement().satisfies(order->{
assertThat(order.customerId()).isEqualTo("B");assertThat(order.availablePackages()).isEqualTo(2);
});
}
/** 构造带权威选择版本、物理组成及阻断事实的来源。 */
private static Candidate candidate(ObjectMapper json,int id,String customer,boolean blocked) throws Exception {
return json.readValue("""
{"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"},"sourceKey":"K%d","sourceVersion":"v%d",
"customerId":"%s","orderNo":"LB1","warehouseId":"9","availablePackages":2,"appointmentBatches":[],"blockReasons":%s,
"selectionGroup":{"groupKey":"G%d","groupVersion":"g%d","complete":true,"blockReasons":[],
"sources":[{"sourceKey":"K%d","customerId":"%s","packages":2,"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"}}],
"pallets":[{"palletNo":"P%d","sourceKeys":["K%d"],"packageCount":2}]}}
""".formatted(id,id,id,id,customer,blocked?"[\"最后一托被占用\"]":"[]",id,id,id,customer,id,id,id,id),Candidate.class);
}
}
@@ -13,6 +13,24 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetHandoverPdaQueryTest {
/** 混排整车109箱,仓内真实发运9箱时按原实载客户及目的地读取交接。 */
@Test void mixedDispatchUsesOriginalWarehouseLoadBeforeAndAfterRemotePickup() {
var binding=bindings.findByDispatchNo("PC-A");binding.setPackageCount(109);
var departed=new FleetTripHistory();departed.setSnapshotJson("""
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL"},
"dispatches":[{"dispatchNo":"PC-A","packageCount":109}],
"confirmedLoad":{"cargo":[{"dispatchNo":"PC-A","customerId":"CUSTOMER-A","warehousePalletId":"501","destinationKey":"FBA:ONE","packages":9}]}}
""");
when(history.selectList(any())).thenReturn(List.of(departed));
var delivery=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A","PC-A","DL-9","FBA:ONE",9,Instant.now(),List.of()),"DELIVERED",1,new FleetTransitEvidence.Transport("ORIGINAL","原司机",null));
for(int shipped:List.of(9,109)){
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",shipped,List.of(delivery)));
assertEquals(9,service.status(92,"PC-A").deliveries().get(0).packages());
}
var wrongCustomer=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("OTHER","PC-A","DL-9","FBA:ONE",9,null,List.of()),"DELIVERING",1,delivery.transport());
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",109,List.of(wrongCustomer)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));
}
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final FleetTripMapper trips = mock(FleetTripMapper.class);
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
@@ -0,0 +1,155 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
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.AfterEach;
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.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
public class TripOperationsRepositoryTest {
private JdbcTemplate jdbc;
private TripOperationsRepository repository;
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
@BeforeEach void setUp() throws Exception {
var source = new DriverManagerDataSource(
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
jdbc = new JdbcTemplate(source);
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)");
jdbc.execute("CREATE TABLE ll_dispatch_order(trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(TripOperationsQueryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source);
factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
repository = new TripOperationsRepository(mapper);
}
/** 释放本例数据库,避免定向回归累积隔离状态。 */
@AfterEach void closeDatabase() {
if (jdbc != null) jdbc.execute("SHUTDOWN");
}
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
public static String jsonExtract(String document, String path) throws Exception {
if (document == null) return null;
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
return value.isMissingNode() ? null : value.toString();
}
/** 保留字符串及 JSON null 的解码语义。 */
public static String jsonUnquote(String document) throws Exception {
return document == null ? null : new ObjectMapper().readTree(document).asText();
}
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
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,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,'LOCAL_TRUCK',9)");
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
assertThat(result.summary().trips()).isEqualTo(3);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
assertThat(result.summary().ownPackages()).isEqualTo(27);
assertThat(result.summary().externalPackages()).isEqualTo(4);
assertThat(result.page().records().get(0).physicalPallets()).isNull();
assertThat(result.page().records().get(1).physicalPallets()).isNull();
}
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
@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");
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
assertThat(repository.departures(query(1L, "COMPLETED")))
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
}
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
assertThatThrownBy(() -> service().report(query(null, null), false))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
}
}
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
assertThat(result.summary().ownPackages()).isZero();
}
}
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
private String transportSnapshot(String number, String purpose) throws Exception {
var json = new ObjectMapper();
var snapshot = json.createObjectNode();
var trip = snapshot.putObject("trip");
trip.put("tripNo", number);
trip.put("tripPurpose", purpose);
trip.put("departedAt", "2026-10-01T00:00:00Z");
var stops = trip.putArray("stops");
stops.addObject().put("name", "起点");
stops.addObject().put("name", "终点");
return json.writeValueAsString(snapshot);
}
/** 创建本例隔离库中的发车事实。 */
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));
}
}
+14 -2
View File
@@ -69,7 +69,8 @@ case "$command" in
esac
`
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) {
// 通过临时 Docker/SSH 替身验证环境选择、健康门禁及失败后的镜像恢复。
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, missingTestLimits = false, topicPrefix = `${environment}-`, remote = false }) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-'))
const mockBin = join(temporaryDirectory, 'bin')
const dockerPath = join(mockBin, 'docker')
@@ -115,7 +116,7 @@ cp "$1" "\${2#*:}"
service,
envFile.replaceAll('\\', '/')
], {
cwd: repositoryRoot,
cwd: missingTestLimits ? temporaryDirectory : repositoryRoot,
encoding: 'utf8',
env: {
...process.env,
@@ -158,6 +159,16 @@ test('successful deployment keeps the new healthy image', async (context) => {
assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/)
assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/)
assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood')
for (const command of result.dockerLog.split(/\r?\n/).filter(line => line.startsWith('compose '))) {
assert.match(command, /-f deploy\/docker-compose\.yml -f deploy\/docker-compose\.test-limits\.yml /)
}
})
test('TEST rejects a missing memory limits file before Docker', async () => {
const result = await runDeployment({ imageTag: 'newgood', missingTestLimits: true })
assert.equal(result.status, 2)
assert.equal(result.dockerLog, '')
assert.match(result.stderr, /TEST memory limits file missing/)
})
test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => {
@@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project',
assert.equal(result.status, 0, result.stderr)
assert.match(result.dockerLog, /--project-name firstleg-production/)
assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/)
assert.doesNotMatch(result.dockerLog, /docker-compose\.test-limits\.yml/)
})
test('gray deployment rejects TEST message scope before any Docker operation', async () => {
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import test from 'node:test'
const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8'))
@@ -15,6 +19,60 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile',
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('TEST rejects old source commits even when the workspace contains newer limits files', {
skip: process.platform === 'win32' && !existsSync('C:/Program Files/Git/bin/bash.exe')
}, async () => {
const directory = await mkdtemp(join(tmpdir(), 'test-memory-source-'))
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const guardStart = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then')
const guardEnd = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then', guardStart)
assert.ok(guardStart >= 0 && guardEnd > guardStart)
assert.ok(guardStart < pipeline.indexOf("stage('Maven 测试与打包')"))
const guard = pipeline.slice(guardStart, guardEnd)
// 在微型 Git 夹具中验证目标提交本身,避免工作区残留文件误放行旧版本。
const git = (...args) => {
const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
return result.stdout.trim()
}
// 构建提交沿用正常身份设置,但禁止夹具触发全局签名及提交钩子。
const commit = (message) => {
git('add', '.')
git('-c', 'user.name=Resource Contract', '-c', 'user.email=resource@example.invalid',
'-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-m', message)
return git('rev-parse', 'HEAD')
}
try {
git('init')
await writeFile(join(directory, 'README.md'), 'Source guard fixture\n')
const oldCommit = commit('old source')
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), '#!/bin/sh\nexit 0\n')
const incompleteCommit = commit('limits file without deployment integration')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
const protectedCommit = commit('complete memory protection')
git('checkout', '--detach', oldCommit)
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
for (const [environment, sourceCommit, status] of [
['test', oldCommit, 1], ['test', incompleteCommit, 1],
['test', protectedCommit, 0], ['production', oldCommit, 0]
]) {
const result = spawnSync(bash, ['-c', guard], {
cwd: directory, encoding: 'utf8', timeout: 10000,
env: { ...process.env, RESOLVED_ENVIRONMENT: environment, RESOLVED_SOURCE_COMMIT: sourceCommit }
})
assert.equal(result.status, status, result.stderr)
if (status !== 0) assert.match(result.stderr, /目标 TEST 提交缺少完整内存保护/)
}
} finally {
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
await rm(directory, { recursive: true, force: true })
}
})
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
for (const source of [coordinatorPipeline, pipeline]) {
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,