This commit is contained in:
2026-08-14 16:06:27 +08:00
parent 87eb1aa72f
commit 87e919f3c4
54 changed files with 984 additions and 175 deletions
@@ -45,12 +45,11 @@ public class PlatformLoadingPdaController {
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String containerNo,
@RequestParam(required = false) String cabinetNo,
@RequestParam(required = false) Long loadingPlanId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId));
return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId));
}
@GetMapping("/tasks/pending")
@@ -60,17 +60,10 @@ public final class LoadingPickDtos {
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Long loadingPlanId,
@NotNull @Min(1) Long containerBookingId,
@Size(max = 64) String containerNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
) {
@Deprecated
public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion,
String inventorySnapshotToken, List<BatchItemRequest> items) {
this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items);
}
}
) {}
public record BatchItemRequest(
@NotNull @Min(1) Long itemId,
@@ -59,6 +59,10 @@ public class OceanBookingRepository {
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) {
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
}
public List<OceanBookingStatusHistoryEntity> histories(long id) { return histories.selectList(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); }
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.service.LoadingPickService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -31,6 +32,7 @@ public class OceanBookingService {
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
@Resource private OceanBookingRepository repository;
@Resource private ObjectMapper objectMapper;
@Resource private LoadingPickService loadingPickService;
public PageResult<OceanBookingEntity> page(Query query) {
String status = trim(query.status());
@@ -106,6 +108,8 @@ public class OceanBookingService {
requireStatus(before, OceanBookingStatus.CONFIRMED);
if (repository.release(id, request, operatorId, name) != 1) throw concurrent();
repository.syncReleasedTransport(id, request, operatorId, name);
repository.allocations(id).forEach(allocation ->
loadingPickService.beginIfReady(allocation.getContainerBookingId(), operatorId, name));
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
@@ -104,23 +104,10 @@ public class ContainerService {
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
if (transportHistories != null) transportHistories.insert(history);
ContainerBookingEntity updated = required(id);
beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName);
loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName);
return updated;
}
/** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */
private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo,
long operatorId, String operatorName) {
if (containerNo == null) return;
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId());
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
// The PDA task and its item versions are stable work identities. A later transport-number
// correction only changes the booking display value and must not resynchronize the task.
if (loadingPickRepository.taskByPlan(plan.getId()) != null) return;
loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName,
smartLoadingRepository.items(plan.getId()));
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
@@ -174,8 +174,12 @@ public class InboundMeasurementService {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
if (wasConfirmed) {
recalculateConfirmedWeight(inbound, operatorId);
}
return toWeight(weight);
}
@@ -33,6 +33,7 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
@@ -65,6 +66,8 @@ public class LoadingPickService {
@Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private OceanBookingRepository oceanBookingRepository;
@Resource
private LoadingListService loadingListService;
@Resource
private PdaPickSettlementService settlementService;
@@ -73,6 +76,22 @@ public class LoadingPickService {
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
/** Opens the field task only after planning, transport identity and ocean release have converged. */
@Transactional
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
if (allocation == null) return;
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) return;
if (repository.taskByPlan(plan.getId()) != null) return;
begin(plan.getId(), booking.getContainerNo(), operatorId, operatorName,
smartLoadingRepository.items(plan.getId()));
}
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
@@ -111,29 +130,14 @@ public class LoadingPickService {
}
}
public Task task(String containerNo, long warehouseId) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
}
return view(task, warehouseId);
}
public Task task(String containerNo, String cabinetNo, Long loadingPlanId, long warehouseId) {
int selectors = (StringUtils.hasText(containerNo) ? 1 : 0) + (StringUtils.hasText(cabinetNo) ? 1 : 0)
public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) {
int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0)
+ (loadingPlanId != null ? 1 : 0);
if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST,
"containerNo、cabinetNo、loadingPlanId必须且只能提供一个");
"cabinetNo、loadingPlanId必须且只能提供一个");
LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo)
? repository.taskByCabinet(cabinetNo.trim(), warehouseId)
: loadingPlanId != null ? repository.taskByPlan(loadingPlanId, warehouseId)
: repository.taskByContainer(containerNo.trim(), warehouseId);
: repository.taskByPlan(loadingPlanId, warehouseId);
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
return view(task, warehouseId);
}
@@ -854,6 +858,7 @@ public class LoadingPickService {
}
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
.append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|')
.append(request.containerBookingId()).append('|')
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
Comparator.nullsFirst(Long::compareTo)))
@@ -49,7 +49,7 @@ public class MerchantPriceBookService {
}
public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode,
String destinationType) {
String destinationType) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
@@ -59,7 +59,7 @@ public class MerchantPriceBookService {
}
var prefix = repository.channelPrefix(normalized);
if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) {
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀");
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置进仓编码");
}
String selected = "OVERSEAS".equals(destinationType) && StringUtils.hasText(prefix.overseasInboundCodePrefix())
? prefix.overseasInboundCodePrefix() : prefix.inboundCodePrefix();
@@ -225,21 +225,22 @@ public class SmartLoadingService {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
if (key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
if (key.equals(plan.getPushIdempotencyKey())) {
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
return view(plan, requireBooking(plan.getContainerBookingId()));
}
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId);
if (items.isEmpty()) throw validation("装柜明细不能为空");
checkPlanCapacity(booking, items);
if (StringUtils.hasText(booking.getContainerNo())) {
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, items);
}
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
@@ -421,8 +421,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀',
overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀',
inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用',
overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
@@ -49,6 +49,26 @@ paths:
- {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
responses:
'200': {description: 幂等补偿完成}
/internal/firstleg/v1/pricing/channels/validate:
post:
summary: 校验订单渠道并返回进仓编码前缀
description: 仅供订单服务调用;前缀按渠道和目的仓类型选择,同一渠道的包税与不包税订单共用编码前缀。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tenantId, originWarehouseId, channelCode, destinationType]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string, minLength: 1, maxLength: 64}
destinationType: {type: string, enum: [FBA, OVERSEAS, PRIVATE]}
responses:
'200': {description: 成功时 data 返回 channelCode 和 inboundCodePrefix;未配置对应进仓编码时返回业务冲突}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户固定四箱规尺寸配置
@@ -7,7 +7,6 @@ paths:
get:
summary: Find the active container picking task in one platform warehouse
parameters:
- {name: containerNo, in: query, required: false, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}}
- {name: cabinetNo, in: query, required: false, schema: {type: string}}
- {name: loadingPlanId, in: query, required: false, schema: {type: integer, format: int64}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -195,7 +194,6 @@ components:
warehouseId: {type: integer, format: int64, minimum: 1}
loadingPlanId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency}
expectedVersion: {type: integer, minimum: 0}
inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64}
items: