This commit is contained in:
2026-08-27 11:38:53 +08:00
parent c1b136c3d6
commit 575cee7a86
6 changed files with 85 additions and 23 deletions
@@ -114,9 +114,10 @@ public class PlatformContainerController {
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request));
}
@GetMapping("/{id}/clearance")
@@ -16,6 +16,8 @@ public class ContainerTransportHistoryEntity {
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String operationId;
private String requestHash;
private String reason;
private Long operatorId;
private String operatorName;
@@ -3,6 +3,13 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {}
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} "
+ "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1")
ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId,
@Param("operatorId") long operatorId, @Param("operationId") String operationId);
}
@@ -36,6 +36,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
@Service
public class ContainerService {
@@ -151,31 +155,83 @@ public class ContainerService {
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
TransportUpdateRequest request) {
ContainerBookingEntity current = required(id);
String idempotencyKey, TransportUpdateRequest request) {
String operationId = transportOperationId(id, idempotencyKey);
String containerNo = trim(request.containerNo());
String voyageNo = trim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + id + '|' + request.version() + '|'
+ Objects.toString(containerNo, "") + '|' + Objects.toString(voyageNo, "") + '|'
+ request.reason().trim());
ContainerTransportHistoryEntity replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return required(id);
}
ContainerBookingEntity current = repository.findForUpdate(id);
if (current == null || !"ACTIVE".equals(current.getRecordStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
}
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo());
replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return current;
}
if (Objects.equals(current.getContainerNo(), containerNo)
&& Objects.equals(current.getVoyageNo(), voyageNo)) {
return current;
}
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id);
if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
if (repository.updateTransport(id, containerNo, voyageNo, request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim());
history.setAfterVoyageNo(voyageNo); history.setOperationId(operationId);
history.setRequestHash(requestHash); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
if (transportHistories != null) transportHistories.insert(history);
try {
if (transportHistories != null) transportHistories.insert(history);
} catch (DuplicateKeyException exception) {
ContainerTransportHistoryEntity concurrent = transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent == null || !requestHash.equals(concurrent.getRequestHash())) {
throw conflict("Idempotency-Key已被并发用于不同运输字段请求");
}
}
ContainerBookingEntity updated = required(id);
loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName);
return updated;
}
private static String transportOperationId(long id, String value) {
String key = trim(value);
if (key == null) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空");
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128");
return sha256("CONTAINER_TRANSPORT|" + id + '|' + key);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256不可用", exception);
}
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
@@ -71,7 +71,6 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts =
async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) {
if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null
const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}`
const resources = []
const candidates = await reusablePallets(context, warehouseId)
if (!candidates) return null
@@ -82,20 +81,9 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa
context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null,
REUSABLE_PALLET_CODES.includes(candidates.find((item) => item.id === palletId)?.code),
`warehouseId=${warehouseId}, palletId=${palletId}`, OPTIONS)
const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}`
let locationId = null
if (context.context?.phase3LoadingRequireSharedOverflow !== true) {
const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', {
warehouseId, zoneCode: `E2E-${String(context.marker).slice(-8)}`, zoneName: '自动化验收区',
locationCode: `E2E-${resourceSuffix}`, locationName: `测量验收库位-${index}`,
locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE',
remark: `${context.marker}-INBOUND-${inboundId}`,
})
locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`,
locationResponse, `warehouseId=${warehouseId}`, OPTIONS))
}
if (context.context?.phase3LoadingRequireSharedOverflow !== true && !locationId) return null
resources.push({ palletId, locationId })
// 库位由 Warehouse 在测量审核时按正式规则分配;普通位不足会复用唯一的 A0 共享溢出位。
// 验收脚本不得为了制造容量而新增普通库位。
resources.push({ palletId, locationId: null })
}
context.createdResources.inboundWarehouseResources ??= {}
@@ -6,6 +6,7 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8')
const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8')
const warehouseLocationResources = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java', import.meta.url), 'utf8')
test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => {
assert.match(acceptance, /--wait-split-package-count <n>/)
@@ -35,6 +36,13 @@ test('称重只随机复用义乌仓原有两个托盘且禁止按订单新增',
assert.doesNotMatch(measurement, /创建称重托盘主数据/)
})
test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => {
assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/locations/)
assert.doesNotMatch(measurement, /创建测量占用库位/)
assert.match(measurement, /普通位不足会复用唯一的 A0 共享溢出位/)
assert.match(warehouseLocationResources, /selected\.size\(\) < quantity[\s\S]*lockOrCreateSharedOverflow/)
})
test('八千票执行器逐票记录且失败后继续', () => {
assert.match(runner, /positiveInteger\(values\.get\('count'\), 8000/)
assert.match(runner, /positiveInteger\(values\.get\('box-max'\), 300/)