优化
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
|
||||
业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。
|
||||
|
||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;车次准备查同包 `FleetTripPreparationService.java`,本土业务接线查 `fleet/business/`;合同 `fleet-preparation-v1.yaml`,流程变更再查 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
|
||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
||||
|
||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
|
||||
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
|
||||
|
||||
### 整车排车的既有预约接线边界(2026-10-19)
|
||||
|
||||
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
|
||||
|
||||
### 平台自营统计边界
|
||||
|
||||
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
|
||||
<configuration><reuseForks>false</reuseForks><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
|
||||
</execution>
|
||||
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
|
||||
<execution>
|
||||
|
||||
@@ -73,6 +73,40 @@ function analyze(sourceSet = 'main') {
|
||||
}));
|
||||
}
|
||||
|
||||
test('original appointment adapters use only the reviewed dispatch port types', () => {
|
||||
const report = analyze();
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const prefix = 'com.baiyue.lastleg.inboundplan.work.';
|
||||
const port = 'com.baiyue.lastleg.dispatch.port.';
|
||||
const expected = [
|
||||
['FbaAppointmentExecutionAdapter', 'DispatchAppointmentAmendmentPort'],
|
||||
['FbaAppointmentExecutionAdapter', 'DispatchContracts'],
|
||||
['FbaAppointmentExecutionAdapter', 'DispatchTypes'],
|
||||
['FbaAppointmentTransportAdapter', 'DispatchAppointmentAmendmentPort'],
|
||||
['FbaAppointmentTransportAdapter', 'DispatchCommands'],
|
||||
['FbaAppointmentTransportAdapter', 'DispatchContracts'],
|
||||
['FbaAppointmentTransportAdapter', 'DispatchTypes'],
|
||||
].map(([source, target]) => `${prefix}${source}->${port}${target}`).sort();
|
||||
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), expected);
|
||||
assert.equal(baseline.allowedSliceEdges.includes('inbound-pda->dispatch-delivery'), false);
|
||||
const violations = contractViolations(report, baseline);
|
||||
for (const edge of expected) assert.equal(violations.some(message => message.includes(edge)), false);
|
||||
|
||||
// 精确端口边不允许原适配器直连整车实现,也不授权其他预约消费者借用此许可。
|
||||
const forbidden = [
|
||||
{ source: prefix + 'FbaAppointmentExecutionAdapter', target: 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' },
|
||||
{ source: prefix + 'AppointmentWorkDispatchBinding', target: port + 'DispatchAppointmentAmendmentPort' },
|
||||
];
|
||||
for (const edge of forbidden) {
|
||||
assert.ok(report.classes.some(entry => entry.fqcn === edge.source));
|
||||
assert.ok(report.classes.some(entry => entry.fqcn === edge.target));
|
||||
report.classDependencies.push(edge);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||
}
|
||||
});
|
||||
|
||||
test('current source does not add slice dependencies beyond the Lastleg baseline', () => {
|
||||
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
|
||||
cwd: moduleRoot,
|
||||
|
||||
+3
-3
@@ -19,11 +19,11 @@ public interface AppointmentImpactRelationMapper {
|
||||
""")
|
||||
List<Appointment> appointments(String id);
|
||||
@Select("""
|
||||
SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o
|
||||
SELECT DISTINCT i.customer_id,d.dispatch_no FROM ll_order o
|
||||
JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id
|
||||
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id
|
||||
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id
|
||||
WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
|
||||
AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001
|
||||
AND d.status='ACTIVE' AND d.stage<>'DELIVERED' ORDER BY d.dispatch_no LIMIT 1001
|
||||
""")
|
||||
List<AppointmentImpactTransportGateway.Dispatch> dispatches(String id);
|
||||
}
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||
|
||||
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
|
||||
/** 普通流水与原箱凭据证明退回,共享任务按真实扫码及逐票客户归属核验。 */
|
||||
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
|
||||
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
|
||||
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
|
||||
@@ -25,7 +25,7 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||
"AND di.warehouse_pallet_id=oi.warehouse_pallet_id " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " +
|
||||
"ON a.customer_id=p.customer_id AND a.pick_id=p.id " +
|
||||
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
|
||||
"JOIN ll_dispatch_pda_task t ON t.id=p.task_id " +
|
||||
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
|
||||
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
|
||||
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ public interface DispatchFeeCargoMapper {
|
||||
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id
|
||||
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id
|
||||
WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId}
|
||||
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
|
||||
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
|
||||
|
||||
+8
@@ -45,6 +45,14 @@ public class DeliveryCancellationRepository {
|
||||
public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); }
|
||||
/** 独立换标出库保留其原域确认锁。 */
|
||||
public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); }
|
||||
/** 仅撤销本次出库关联,原生箱和普通仓托均保留客户已确认成果;旧号必须显式清空。 */
|
||||
public boolean restoreRelabelArrangement(RelabelApplication application,String outboundNo,String fulfillment,LocalDateTime now) {
|
||||
return relabels.update(null,new LambdaUpdateWrapper<RelabelApplication>()
|
||||
.eq(RelabelApplication::getId,application.getId()).eq(RelabelApplication::getCustomerId,application.getCustomerId())
|
||||
.eq(RelabelApplication::getOutboundOrderNo,outboundNo).eq(RelabelApplication::getStatus,"READY_FOR_OUTBOUND")
|
||||
.set(RelabelApplication::getOutboundOrderNo,null).set(RelabelApplication::getFulfillmentStatus,fulfillment)
|
||||
.set(RelabelApplication::getUpdatedAt,now))==1;
|
||||
}
|
||||
/** 子明细始终按出库客户读取。 */
|
||||
public List<OutboundPalletItem> items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); }
|
||||
/** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */
|
||||
|
||||
+21
-10
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
@@ -22,9 +21,9 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
@@ -93,7 +92,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DeliveryTransferService {
|
||||
@Resource private DispatchOrderMapper dispatchOrders;
|
||||
@Resource private DispatchAggregateRepository dispatches;
|
||||
@Resource private DispatchItemMapper dispatchItems;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundPallets;
|
||||
@@ -128,7 +127,6 @@ public class DeliveryTransferService {
|
||||
@Resource private RelabelApplicationService relabelService;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private DeliveryExecutionGuard deliveryExecution;
|
||||
@Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
|
||||
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
|
||||
@@ -912,7 +910,7 @@ public class DeliveryTransferService {
|
||||
|| dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING
|
||||
|| dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0)
|
||||
throw conflict("排车已开始执行,不能直接取消出库");
|
||||
fleetBindings.requireUnbound(dispatch.dispatchId());
|
||||
// 唯一Fleet资源记录随整车自动创建,不能把本车正常关系当作额外取消阻断。
|
||||
}
|
||||
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
|
||||
throw conflict("Only an unexecuted outbound order can be cancelled");
|
||||
@@ -923,6 +921,8 @@ public class DeliveryTransferService {
|
||||
|| !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) {
|
||||
throw conflict("Outbound order cannot be cancelled after execution has started");
|
||||
}
|
||||
boolean relabelArrangement = "RELABEL_OUTBOUND".equals(outbound.getOutboundType());
|
||||
String pendingRelabelFulfillment = null;
|
||||
List<DeliveryOrder> existingDeliveries = cancellations.deliveries(outbound);
|
||||
if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus())))
|
||||
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
|
||||
@@ -932,6 +932,7 @@ public class DeliveryTransferService {
|
||||
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
if (application == null || !customerId.equals(application.getCustomerId())
|
||||
|| !outbound.getOutboundNo().equals(application.getOutboundOrderNo())
|
||||
|| !Objects.equals(outbound.getOverseasWarehouseId(),application.getOverseasWarehouseId())
|
||||
|| !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus()))
|
||||
throw conflict("换标出库关联状态已变化,不能取消");
|
||||
for (OutboundPalletItem item : outboundItems) {
|
||||
@@ -939,6 +940,7 @@ public class DeliveryTransferService {
|
||||
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
|
||||
ReturnInventoryCarton carton = cancellations.lockCarton(mark);
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !Objects.equals(outbound.getOverseasWarehouseId(),carton.getOverseasWarehouseId())
|
||||
|| !Objects.equals(application.getId(), carton.getRelabelApplicationId())
|
||||
|| !"OUTBOUND_PENDING".equals(carton.getStatus()))
|
||||
throw conflict("退货换标箱预留状态异常,不能取消出库");
|
||||
@@ -946,8 +948,10 @@ public class DeliveryTransferService {
|
||||
cancellations.save(carton);
|
||||
}
|
||||
}
|
||||
relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId,
|
||||
new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim()));
|
||||
// 原生箱和普通仓托均只撤销本次安排;整车分支仍须先通过父锁、阶段和零扫码门禁。
|
||||
pendingRelabelFulfillment = "TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION";
|
||||
if (!cancellations.restoreRelabelArrangement(application,outbound.getOutboundNo(),pendingRelabelFulfillment,now))
|
||||
throw conflict("原换标出库关联已变化,未取消本次安排");
|
||||
}
|
||||
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) {
|
||||
@@ -968,6 +972,13 @@ public class DeliveryTransferService {
|
||||
var current=cancellations.lockSource(customerId,selected.getId());
|
||||
if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current);
|
||||
}
|
||||
if (relabelArrangement) {
|
||||
// 仅清本次出库/派送关联,来源主状态和换标完成事实保持原值,不能退回未换标库存。
|
||||
for (var order : cancelSources.values()) {
|
||||
if (!cancellations.restore(order,outboundNo,order.getCurrentStatus(),"RELABEL:"+pendingRelabelFulfillment,now))
|
||||
throw conflict("原换标来源出库关联已变化,请刷新核对");
|
||||
}
|
||||
}
|
||||
for (DeliveryOrder delivery : existingDeliveries) {
|
||||
int voidRequests = 0;
|
||||
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
|
||||
@@ -1066,7 +1077,7 @@ public class DeliveryTransferService {
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(code);
|
||||
if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) {
|
||||
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo());
|
||||
DispatchAggregate dispatch = dispatches.find(outbound.getDispatchNo());
|
||||
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
|
||||
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(),
|
||||
dispatch.getDispatchNo());
|
||||
@@ -1076,7 +1087,7 @@ public class DeliveryTransferService {
|
||||
if (task != null) return outboundResolution(outbound, task);
|
||||
}
|
||||
|
||||
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code);
|
||||
DispatchAggregate dispatch = dispatches.find(code);
|
||||
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
|
||||
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo());
|
||||
}
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
|
||||
/** 外接绑定和约定运费共同消费同一份逐来源货物。 */
|
||||
@Service
|
||||
public class DispatchCargoSourceQuery {
|
||||
private final DispatchCargoSourceRepository repository;
|
||||
/** 装配当前库存与本次拣货的真实来源入口。 */
|
||||
public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; }
|
||||
|
||||
/** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */
|
||||
public Set<Long> ordinaryOrderIds(DispatchOrder dispatch, Set<String> externalInboundCodes) {
|
||||
return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream()
|
||||
.filter(row -> !externalInboundCodes.contains(row.inboundCode()))
|
||||
.map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
/** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */
|
||||
public List<FleetDispatchGateway.SourceCargo> sources(DispatchOrder dispatch, List<DispatchItem> items) {
|
||||
if (items.isEmpty()) return List.of();
|
||||
var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId());
|
||||
var result = new ArrayList<FleetDispatchGateway.SourceCargo>();
|
||||
for (var item : items) {
|
||||
String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(),
|
||||
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode());
|
||||
// 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。
|
||||
if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) {
|
||||
result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
|
||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(),
|
||||
item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination));
|
||||
continue;
|
||||
}
|
||||
var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList();
|
||||
if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount())
|
||||
throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源");
|
||||
for (var row : selected) {
|
||||
if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0
|
||||
|| !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId()))
|
||||
throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对");
|
||||
result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(),
|
||||
row.palletNo(), Math.toIntExact(row.packages()), destination));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 来源不足直接阻断,不回退展示字段。 */
|
||||
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
|
||||
}
|
||||
-2
@@ -3,8 +3,6 @@ package com.baiyue.lastleg.dispatch.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
/** One independent dispatch for one server-owned loading container. */
|
||||
public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { }
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
|
||||
|
||||
public final class DispatchRequests {
|
||||
private DispatchRequests() {
|
||||
}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
|
||||
String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public enum Stage {
|
||||
BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED;
|
||||
public List<String> statuses() {
|
||||
return switch (this) {
|
||||
case BEFORE_LOADING -> List.of("PENDING_DEPARTURE");
|
||||
case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART");
|
||||
case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED");
|
||||
case CANCELLED -> List.of("CANCELLED");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
@Size(max = 64) String draftToken,
|
||||
@Size(max = 64) String relabelNo,
|
||||
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
|
||||
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
|
||||
public PreArrange {
|
||||
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
|
||||
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
|
||||
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
|
||||
}
|
||||
public PreArrange(List<Long> planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List<Long> returnReceiptIds) {
|
||||
this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of());
|
||||
}
|
||||
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus,
|
||||
String vehicleAppointmentStatus, String draftToken, String relabelNo) {
|
||||
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of());
|
||||
}
|
||||
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
|
||||
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
|
||||
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
|
||||
}
|
||||
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
|
||||
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
|
||||
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId,
|
||||
Long returnReceiptId, String sourceType) {
|
||||
public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); }
|
||||
public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); }
|
||||
@jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源")
|
||||
public boolean isValidSource() {
|
||||
return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType)
|
||||
: planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType));
|
||||
}
|
||||
}
|
||||
|
||||
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
|
||||
|
||||
public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken,
|
||||
@NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {}
|
||||
|
||||
public record DraftGroup(@NotNull Long draftContainerId,
|
||||
@Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
|
||||
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
|
||||
public DraftGroup {
|
||||
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
|
||||
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
|
||||
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
|
||||
}
|
||||
public DraftGroup(Long id,List<Long> pallets,List<Long> receipts) { this(id,pallets,receipts,List.of()); }
|
||||
}
|
||||
|
||||
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
|
||||
}
|
||||
|
||||
public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken,
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> planIds,
|
||||
@NotBlank @Size(max = 20000) String containersJson) {
|
||||
}
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||
LocalDateTime departAt,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
LocalDateTime estimatedArrivalAt) {
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
|
||||
String driverPhone, String carrierName, String orderNos, String route,
|
||||
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
|
||||
String fbaAppointmentNo, String fbaAppointmentStatus,
|
||||
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
|
||||
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
|
||||
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
|
||||
LocalDateTime createdAt,
|
||||
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches,
|
||||
Actions actions) {
|
||||
public record Actions(boolean canEdit, String editBlockReason,
|
||||
boolean canCancel, String cancelBlockReason,
|
||||
boolean canRemovePallet, String removePalletBlockReason,
|
||||
boolean canTransfer, String transferBlockReason,
|
||||
String outboundNo, String outboundStatus) { }
|
||||
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeWeightKg,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
}
|
||||
|
||||
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
|
||||
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
|
||||
String poReferenceNo, int packageCount) {
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 普通换标逐item/实际仓位的冻结贡献,原货源与换标履约单分开保留。 */
|
||||
@Data
|
||||
@TableName("ll_dispatch_item_source")
|
||||
public class DispatchItemSource {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private Long dispatchOrderId;
|
||||
private Long dispatchItemId;
|
||||
private String customerId;
|
||||
private Long relabelApplicationId;
|
||||
private Long relabelItemId;
|
||||
private String relabelRoundKey;
|
||||
private Long warehousePalletId;
|
||||
private Long inventoryPositionId;
|
||||
private Long inventoryBalanceId;
|
||||
private Long sourceInventoryBalanceId;
|
||||
private Long sourceInboundOrderId;
|
||||
private String sourceInboundOrderNo;
|
||||
private String sourceInboundCode;
|
||||
private Long notifiedPackageCount;
|
||||
private Long packageCount;
|
||||
private Long productQuantity;
|
||||
private String bindingBasisHash;
|
||||
private String unitFactsJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
|
||||
@Data
|
||||
@TableName("ll_dispatch_order")
|
||||
public class DispatchOrder {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String dispatchNo;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String vehiclePlate;
|
||||
private String driverName;
|
||||
private String driverPhone;
|
||||
private String carrierName;
|
||||
private String route;
|
||||
private LocalDateTime departAt;
|
||||
private String fbaAppointmentNo;
|
||||
private String fbaAppointmentStatus;
|
||||
private String vehicleAppointmentStatus;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private String status;
|
||||
private Integer itemCount;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private Integer shippedPackageCount;
|
||||
private String partialCompletionReason;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+3
-2
@@ -9,15 +9,16 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Prevents a source pallet from being added to more than one active dispatch order. */
|
||||
/** 真实客户逐逻辑来源的活动占用,实际实托排他归独立物理占用行。 */
|
||||
@Data
|
||||
@TableName("ll_dispatch_pallet_lock")
|
||||
public class DispatchPalletLock {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long physicalLockId;
|
||||
private String customerId;
|
||||
private String sourcePalletKey;
|
||||
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
|
||||
/** 活动时为完整逻辑来源键,释放后为空;不同换标来源不再共用一个来源键。 */
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String activeKey;
|
||||
private Long planId;
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 实托排他占用归主单或草稿容器,不以任何逻辑来源或客户代表整托。 */
|
||||
@Data
|
||||
@TableName("ll_dispatch_physical_pallet_lock")
|
||||
public class DispatchPhysicalPalletLock {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private String warehouseId;
|
||||
private Long warehousePalletId;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private Long activeWarehousePalletId;
|
||||
private Long dispatchOrderId;
|
||||
private String draftToken;
|
||||
private Long draftContainerId;
|
||||
private String operatorId;
|
||||
private String status;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime lockedAt;
|
||||
private LocalDateTime releasedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+1
-1
@@ -25,6 +25,6 @@ public interface DispatchBolMapper {
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
|
||||
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
|
||||
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
|
||||
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
|
||||
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
}
|
||||
|
||||
+6
-6
@@ -52,11 +52,12 @@ public interface DispatchCandidateMapper {
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
@@ -116,9 +117,8 @@ public interface DispatchCandidateMapper {
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
||||
"AND (d.expires_at IS NULL OR d.expires_at > CURRENT_TIMESTAMP(3)) AND (" +
|
||||
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
||||
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
|
||||
"AND (d.expires_at IS NULL OR d.expires_at > CURRENT_TIMESTAMP(3)) AND " +
|
||||
"d.source_pallet_key=CONCAT('PLAN:',p.id) " +
|
||||
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
|
||||
// Incoming creation mode never grants ownership of a later transfer reservation.
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
|
||||
+8
-8
@@ -7,21 +7,21 @@ import java.util.List;
|
||||
/** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */
|
||||
@Mapper
|
||||
public interface DispatchDraftClaimMapper {
|
||||
record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt) { }
|
||||
record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt,Long physicalLockId) { }
|
||||
|
||||
/** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */
|
||||
@Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt "
|
||||
@Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt,physical_lock_id AS physicalLockId "
|
||||
+ "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL "
|
||||
+ "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE")
|
||||
List<Claim> owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 与正式claim共用两项唯一约束,跨客户同实托也不得重复占用。 */
|
||||
@Insert("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,active_warehouse_pallet_id,"
|
||||
/** 每个逻辑来源独立保留客户和来源键,同容器同实托共享已取得的物理占用。 */
|
||||
@Insert("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,"
|
||||
+ "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) "
|
||||
+ "VALUES(#{id},#{customer},#{key},#{key},#{plan},#{pallet},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})")
|
||||
+ "VALUES(#{id},#{physical},#{customer},#{key},#{key},#{plan},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})")
|
||||
int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan,
|
||||
@Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token,
|
||||
@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
|
||||
@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires,@Param("physical")Long physical);
|
||||
|
||||
/** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} "
|
||||
@@ -29,8 +29,8 @@ public interface DispatchDraftClaimMapper {
|
||||
int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous,
|
||||
@Param("next")long next,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 按精确原身份释放,保留历史行并同时清空来源键和全局实托键。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} "
|
||||
/** 按精确原身份释放逻辑来源;物理占用由最后成员结束后经共同提供者释放。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} "
|
||||
+ "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' "
|
||||
+ "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})")
|
||||
int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at);
|
||||
|
||||
+22
-7
@@ -11,16 +11,16 @@ public interface DispatchExecutionReadMapper {
|
||||
record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { }
|
||||
record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo,
|
||||
String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId,
|
||||
Long returnCartonId,String cartonMark,long packages,long productQuantity) { }
|
||||
Long returnCartonId,String cartonMark,long packages,long productQuantity,Long dispatchItemSourceId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { }
|
||||
/** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */
|
||||
@Select("""
|
||||
SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId,
|
||||
o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId,
|
||||
COALESCE(r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId,
|
||||
COALESCE(s.source_inbound_code,r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId,
|
||||
a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId,
|
||||
c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity
|
||||
c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity,a.dispatch_item_source_id AS dispatchItemSourceId
|
||||
FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id
|
||||
JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id
|
||||
@@ -28,18 +28,32 @@ public interface DispatchExecutionReadMapper {
|
||||
LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id
|
||||
LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id
|
||||
LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id
|
||||
LEFT JOIN ll_dispatch_item_source s ON s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id AND s.dispatch_item_id=i.id
|
||||
WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id
|
||||
""")
|
||||
List<PickSource> sources(@Param("id")long dispatchId);
|
||||
/** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id")
|
||||
List<ItemPicked> itemPicked(@Param("id")long dispatchId);
|
||||
/** 两端共同校验四种来源和逐贡献额度,避免同托来源互相补数量而放行。 */
|
||||
String INVALID_PICK="""
|
||||
(p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages
|
||||
OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0)
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id
|
||||
AND NOT EXISTS(SELECT 1 FROM ll_order o WHERE o.id=a.order_id AND o.customer_id=a.customer_id))
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NOT NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item_source s WHERE s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id
|
||||
AND s.dispatch_order_id=i.dispatch_order_id AND s.dispatch_item_id=i.id AND s.inventory_position_id=p.inventory_position_id
|
||||
AND s.warehouse_pallet_id=i.warehouse_pallet_id AND i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER'
|
||||
AND (SELECT COALESCE(SUM(x.packages),0) FROM ll_dispatch_pda_pick_order x WHERE x.dispatch_item_source_id=s.id AND x.customer_id=s.customer_id)<=s.package_count))
|
||||
OR (i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' AND EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a
|
||||
WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NULL)))
|
||||
""";
|
||||
/** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id
|
||||
WHERE i.dispatch_order_id=#{id} AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages
|
||||
OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0))
|
||||
""")
|
||||
WHERE i.dispatch_order_id=#{id} AND
|
||||
"""+INVALID_PICK)
|
||||
long inconsistentPicks(@Param("id")long dispatchId);
|
||||
/** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */
|
||||
@Select("""
|
||||
@@ -47,7 +61,8 @@ public interface DispatchExecutionReadMapper {
|
||||
p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm
|
||||
FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id
|
||||
JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id
|
||||
WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=i.package_count AND p.completed_at IS NOT NULL
|
||||
WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=(SELECT SUM(x.package_count) FROM ll_dispatch_item x
|
||||
WHERE x.dispatch_order_id=i.dispatch_order_id AND x.warehouse_pallet_id=i.warehouse_pallet_id) AND p.completed_at IS NOT NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id
|
||||
AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND'))
|
||||
ORDER BY p.warehouse_pallet_id,p.completed_at
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItemSource;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 不可变来源快照只追加或随零扫码移货删除,不用聚合库存回推历史贡献。 */
|
||||
@Mapper
|
||||
public interface DispatchItemSourceMapper extends BaseMapper<DispatchItemSource> {
|
||||
record PickedSource(Long sourceId,long packages,long productQuantity) { }
|
||||
/** 读取整车完整贡献,客户和逻辑明细身份始终随行保留。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} ORDER BY customer_id,dispatch_item_id,inventory_position_id,relabel_item_id,id")
|
||||
List<DispatchItemSource> all(@Param("dispatch")long dispatch);
|
||||
|
||||
/** PDA列表一次读取所有本批主单贡献,不逐车逐来源查询。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,dispatch_item_id,inventory_position_id,relabel_item_id,id</script>")
|
||||
List<DispatchItemSource> batchAll(@Param("ids")List<Long> ids);
|
||||
|
||||
/** 一次核对冻结贡献的实际已消费箱件,不用同position其他申请的拣货抵消额度。 */
|
||||
@Select("<script>SELECT s.id AS sourceId,COALESCE(SUM(a.packages),0) AS packages,COALESCE(SUM(a.product_quantity),0) AS productQuantity "
|
||||
+ "FROM ll_dispatch_item_source s LEFT JOIN ll_dispatch_pda_pick_order a ON a.dispatch_item_source_id=s.id AND a.customer_id=s.customer_id "
|
||||
+ "WHERE s.dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> GROUP BY s.id ORDER BY s.id</script>")
|
||||
List<PickedSource> pickedAll(@Param("ids")List<Long> ids);
|
||||
|
||||
/** 扫码位置只能消费所选真实客户和逻辑明细的贡献。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} AND customer_id=#{customer} "
|
||||
+ "AND inventory_position_id=#{position} ORDER BY relabel_item_id,id")
|
||||
List<DispatchItemSource> position(@Param("dispatch")long dispatch,@Param("item")long item,
|
||||
@Param("customer")String customer,@Param("position")long position);
|
||||
|
||||
/** 额度按不可变真实拣货归属累计,不能以整仓位已拣数代替本来源已拣数。 */
|
||||
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customer} AND dispatch_item_source_id=#{source}")
|
||||
long picked(@Param("customer")String customer,@Param("source")long source);
|
||||
|
||||
/** 零扫码移货才允许删除;已有实际归属时由SQL再次阻断。 */
|
||||
@Delete("DELETE FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} "
|
||||
+ "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.dispatch_item_source_id=ll_dispatch_item_source.id)")
|
||||
int removeUnpicked(@Param("dispatch")long dispatch,@Param("item")long item);
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
}
|
||||
-7
@@ -23,13 +23,6 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
|
||||
List<DispatchPalletLock> lockActiveByDraftContainer(@Param("draftToken") String draftToken,
|
||||
@Param("draftContainerId") Long draftContainerId);
|
||||
|
||||
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
|
||||
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " +
|
||||
"WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " +
|
||||
"ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
|
||||
Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken,
|
||||
@Param("draftContainerId") Long draftContainerId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
|
||||
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||
DispatchPalletLock lockActive(@Param("customerId") String customerId,
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 物理层只保存实托与真正拥有者,逻辑来源份额仍由各自客户成员保存。 */
|
||||
@Mapper
|
||||
public interface DispatchPhysicalClaimMapper extends BaseMapper<DispatchPhysicalPalletLock> {
|
||||
/** 到期但尚未清键的原行也必须先锁定,不能越过原拥有者领取。 */
|
||||
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=#{pallet} FOR UPDATE")
|
||||
DispatchPhysicalPalletLock lockActive(@Param("pallet")long pallet);
|
||||
|
||||
/** 释放按原物理占用主键取得,不能按实托号误释放新拥有者的后续占用。 */
|
||||
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id=#{id} FOR UPDATE")
|
||||
DispatchPhysicalPalletLock lockOriginal(@Param("id")long id);
|
||||
|
||||
/** 只读原实托身份供服务排序,再逐主键取锁,避免filesort之前按主键先锁的执行计划。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY warehouse_pallet_id,id</script>")
|
||||
List<DispatchPhysicalPalletLock> originals(@Param("ids")List<Long> ids);
|
||||
|
||||
/** 批量释放、转换与续约前按物理托盘顺序收齐本主单的原行。 */
|
||||
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=#{dispatch} AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
|
||||
List<DispatchPhysicalPalletLock> lockDispatch(@Param("dispatch")long dispatch);
|
||||
|
||||
/** 草稿归属同时包含token与操作人,容器身份由每条原行保留。 */
|
||||
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE draft_token=#{token} AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
|
||||
List<DispatchPhysicalPalletLock> lockDraft(@Param("token")String token,@Param("operator")String operator);
|
||||
|
||||
/** 仅使该原物理占用下已到期的草稿成员失效,不释放正式来源或已经续期的成员。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,released_at=#{at},updated_at=#{at} "
|
||||
+ "WHERE physical_lock_id=#{id} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} "
|
||||
+ "AND EXISTS(SELECT 1 FROM ll_dispatch_physical_pallet_lock p WHERE p.id=#{id} AND p.status='ACTIVE' AND p.dispatch_order_id IS NULL "
|
||||
+ "AND p.draft_token=ll_dispatch_pallet_lock.draft_token AND p.draft_container_id=ll_dispatch_pallet_lock.draft_container_id "
|
||||
+ "AND p.operator_id=ll_dispatch_pallet_lock.operator_id AND p.expires_at<=#{at})")
|
||||
int expireMembers(@Param("id")long id,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 最后活动成员结束后才能清物理键;原id保证不会触碰同托后续的新占用。 */
|
||||
@Update("UPDATE ll_dispatch_physical_pallet_lock SET status=#{status},active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} "
|
||||
+ "WHERE id=#{id} AND status='ACTIVE' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} AND l.status='ACTIVE')")
|
||||
int releaseUnused(@Param("id")long id,@Param("status")String status,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 草稿成员已经在同事务全部移入目标容器后,才允许迁移物理拥有者。 */
|
||||
@Update("UPDATE ll_dispatch_physical_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} "
|
||||
+ "AND draft_token=#{token} AND operator_id=#{operator} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL "
|
||||
+ "AND status='ACTIVE' AND expires_at>#{at} AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} "
|
||||
+ "AND l.status='ACTIVE' AND (l.dispatch_order_id IS NOT NULL OR l.draft_token IS NULL OR l.draft_token<>#{token} "
|
||||
+ "OR l.draft_container_id IS NULL OR l.draft_container_id<>#{next} OR l.operator_id IS NULL OR l.operator_id<>#{operator}))")
|
||||
int moveDraft(@Param("id")long id,@Param("token")String token,@Param("operator")String operator,
|
||||
@Param("previous")long previous,@Param("next")long next,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 活动续约不复活已到期记录,期限与逻辑来源及草稿根一起提交。 */
|
||||
@Update("UPDATE ll_dispatch_physical_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} "
|
||||
+ "AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}")
|
||||
int renewDraft(@Param("token")String token,@Param("operator")String operator,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
|
||||
}
|
||||
+69
-23
@@ -13,6 +13,54 @@ public interface DispatchSourceMapper {
|
||||
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
||||
record Claim(Long id, Long dispatchId, String draftToken) { }
|
||||
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
||||
record NativeApplication(String customerId,String relabelNo) { }
|
||||
record PlanReference(Long planPalletId,String customerId) { }
|
||||
record ReturnReference(Long receiptId,String customerId) { }
|
||||
record OrderReference(Long orderId,String customerId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
|
||||
|
||||
/** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */
|
||||
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
|
||||
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
|
||||
+ "AND CAST(s.overseas_warehouse_id AS CHAR(128))=CAST(ip.overseas_warehouse_id AS CHAR(128)) "
|
||||
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
|
||||
|
||||
/** 退回实收的事实身份不因当前草稿占用而消失。 */
|
||||
@Select("<script>SELECT r.id AS receiptId,r.customer_id AS customerId FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
|
||||
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE p.overseas_warehouse_id=#{warehouse} AND p.pallet_id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY r.customer_id,r.id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<ReturnReference> physicalReturns(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
|
||||
|
||||
/** 当前余额对应的真实实收订单参加共同锁序,不拿展示MIN或原货源快照ID猜订单表身份。 */
|
||||
@Select("<script>SELECT DISTINCT x.order_id AS orderId,x.customer_id AS customerId FROM ("
|
||||
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_inventory_position p ON p.balance_id=c.balance_id AND p.customer_id=c.customer_id "
|
||||
+ "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=p.id AND r.customer_id=p.customer_id WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> UNION "
|
||||
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_return_stock_receipt r ON r.inventory_balance_id=c.balance_id AND r.customer_id=c.customer_id "
|
||||
+ "WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>) x ORDER BY x.order_id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<OrderReference> physicalOrders(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
|
||||
|
||||
/** 共同串行只补齐真实身份锁,不给额外原货源或仓内操作新增暂扣资格。 */
|
||||
@Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} FOR UPDATE")
|
||||
Long lockOrderIdentity(@Param("customer")String customer,@Param("id")long id);
|
||||
|
||||
/** 原生退货实托仍由真实箱唛与申请号关联,不借普通库存仓位推断。 */
|
||||
@Select("<script>SELECT DISTINCT customer_id AS customerId,relabel_no AS relabelNo FROM ll_return_inventory_carton "
|
||||
+ "WHERE overseas_warehouse_id=#{warehouse} AND status='RELABEL_PENDING' AND relabel_no IS NOT NULL "
|
||||
+ "AND pallet_code IN <foreach collection='codes' item='code' open='(' separator=',' close=')'>#{code}</foreach> ORDER BY relabel_no</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<NativeApplication> nativeApplications(@Param("warehouse")String warehouse,@Param("codes")List<String> codes);
|
||||
|
||||
/** 换标单客户由服务端业务单号查得,不接受客户端指定。 */
|
||||
@Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}")
|
||||
@@ -52,6 +100,7 @@ public interface DispatchSourceMapper {
|
||||
+ "hold_packages AS holdPackages,allocated_packages AS allocatedPackages,picked_packages AS pickedPackages,version "
|
||||
+ "FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id=#{pallet} ORDER BY id"
|
||||
+ "<if test='lock'> FOR UPDATE</if></script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<Stock> stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock);
|
||||
|
||||
/** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */
|
||||
@@ -63,35 +112,32 @@ public interface DispatchSourceMapper {
|
||||
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
|
||||
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
|
||||
|
||||
/** 仅失效的草稿占用可让出唯一键,绝不释放活动正式单。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} "
|
||||
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} "
|
||||
+ "AND ((customer_id=#{customer} AND source_pallet_key=#{key}) OR active_warehouse_pallet_id=#{pallet})")
|
||||
int expire(@Param("customer") String customer, @Param("key") String key, @Param("pallet") Long pallet,
|
||||
@Param("at") LocalDateTime at);
|
||||
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
|
||||
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
||||
int expire(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at);
|
||||
|
||||
/** 不同来源别名指向同一实托时只能归同一整车,客户不作为实托锁分片。 */
|
||||
/** 逻辑键只代表本客户的这条来源,不能因同托已有其他来源就跳过插入。 */
|
||||
@Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock "
|
||||
+ "WHERE status='ACTIVE' AND ((customer_id=#{customer} AND active_key=#{key}) OR active_warehouse_pallet_id=#{pallet}) "
|
||||
+ "WHERE status='ACTIVE' AND customer_id=#{customer} AND active_key=#{key} "
|
||||
+ "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE")
|
||||
List<Claim> claims(@Param("customer") String customer, @Param("key") String key,
|
||||
@Param("pallet") Long pallet, @Param("at") LocalDateTime at);
|
||||
List<Claim> claims(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at);
|
||||
|
||||
/** 只释放本车本来源原占用,清空实托唯一键,不触碰后续他车占用。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND status='ACTIVE' AND ((#{plan} IS NOT NULL AND plan_pallet_id=#{plan}) OR (#{receipt} IS NOT NULL AND return_receipt_id=#{receipt}) OR (#{pallet} IS NOT NULL AND warehouse_pallet_id=#{pallet}))")
|
||||
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("receipt")Long receipt,@Param("pallet")Long pallet,@Param("at")LocalDateTime at);
|
||||
/** 精确释放本车本客户完整来源键,不能按共享实托一起释放另一申请。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'")
|
||||
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 原计划自然收货后取得同一实托唯一键,其他车/来源的占用不能被覆盖。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},active_warehouse_pallet_id=#{pallet},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL")
|
||||
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("at")LocalDateTime at);
|
||||
/** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL AND physical_lock_id IS NULL")
|
||||
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 唯一活动实托键与来源键共同阻止并发跨车重复排货。 */
|
||||
/** 本来源活动唯一键独立保存,实际实托必须关联本拥有者的物理占用。 */
|
||||
@Insert("""
|
||||
INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,
|
||||
warehouse_pallet_id,active_warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at)
|
||||
VALUES(#{id},#{customer},#{key},#{key},#{planPallet},#{pallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at})
|
||||
INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,
|
||||
warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at)
|
||||
VALUES(#{id},#{physical},#{customer},#{key},#{key},#{planPallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at})
|
||||
""")
|
||||
int claim(@Param("id") long id, @Param("customer") String customer, @Param("key") String key,
|
||||
@Param("planPallet") Long planPallet, @Param("pallet") Long pallet, @Param("receipt") Long receipt,
|
||||
@Param("dispatch") long dispatch, @Param("operator") String operator, @Param("at") LocalDateTime at);
|
||||
int claim(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,
|
||||
@Param("planPallet")Long planPallet,@Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("physical")Long physical,
|
||||
@Param("dispatch")long dispatch,@Param("operator")String operator,@Param("at")LocalDateTime at);
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ public interface ReturnDispatchCandidateMapper {
|
||||
"<if test='orderNo != null'>AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR b.inbound_code LIKE CONCAT('%',#{orderNo},'%') OR r.receipt_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
"<if test='fba != null'>AND d.destination_warehouse_code=#{fba} </if>" +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.customer_id=r.customer_id " +
|
||||
"AND k.source_pallet_key=CONCAT('RECEIVED:',p.pallet_id) AND k.status='ACTIVE' " +
|
||||
"AND k.source_pallet_key=CONCAT('RETURN:',r.id) AND k.status='ACTIVE' " +
|
||||
"AND (k.expires_at IS NULL OR k.expires_at>CURRENT_TIMESTAMP) AND (#{draftToken} IS NULL OR k.draft_token IS NULL OR k.draft_token != #{draftToken})) " +
|
||||
"ORDER BY r.received_at,r.id";
|
||||
@Select("<script>" + AVAILABLE_BODY + "</script>")
|
||||
|
||||
@@ -8,6 +8,7 @@ public record DispatchBolData(String dispatchNo,String overseasWarehouseId,Strin
|
||||
String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId,
|
||||
String poReferenceNo,int packageCount) {
|
||||
public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);}
|
||||
public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
|
||||
public String groupKey(){return customerId+"\u0000"+destinationKey();}
|
||||
}
|
||||
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class DispatchPdaRequests {
|
||||
private DispatchPdaRequests() {}
|
||||
public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
|
||||
@NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) {
|
||||
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
|
||||
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
|
||||
}
|
||||
}
|
||||
public record Depart(@NotBlank String idempotencyKey, String remark) {}
|
||||
public record CompletePicking(@NotBlank String idempotencyKey) {}
|
||||
}
|
||||
+3
-40
@@ -1,43 +1,6 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||
|
||||
import java.util.List;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||
|
||||
/** 原生退货换标箱的内部读取投影。 */
|
||||
public final class DispatchPdaResponses {
|
||||
private DispatchPdaResponses() {}
|
||||
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
|
||||
boolean canDepart, String departBlockReason, List<Item> items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning,
|
||||
boolean canPick, String pickBlockReason) {
|
||||
public Task withGate(FleetExecutionModels.Gate gate) {
|
||||
return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items,
|
||||
gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason());
|
||||
}
|
||||
}
|
||||
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
|
||||
int scheduledPackages,
|
||||
long pickedPackages, List<Position> positions, List<ReturnCarton> returnCartons) {
|
||||
public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
|
||||
int scheduledPackages, long pickedPackages, List<Position> positions) {
|
||||
this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode,
|
||||
warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of());
|
||||
}
|
||||
}
|
||||
public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {}
|
||||
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
|
||||
long pickedPackages) {}
|
||||
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
|
||||
boolean palletScrapped, boolean replayed) {}
|
||||
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
|
||||
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
|
||||
}
|
||||
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
|
||||
long pickedPackages, long remainingPackages, boolean partial,
|
||||
boolean replayed) {
|
||||
public PickingCompleted withReplay() {
|
||||
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
|
||||
remainingPackages, partial, true);
|
||||
}
|
||||
}
|
||||
private DispatchPdaResponses() { }
|
||||
public record ReturnCarton(Long id,String cartonMark,String status,long productQuantity) { }
|
||||
}
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ public class DispatchPdaPickOrder {
|
||||
private Long receiptId;
|
||||
private Long returnReceiptId;
|
||||
private Long returnCartonId;
|
||||
private Long dispatchItemSourceId;
|
||||
private Long orderId;
|
||||
private Long packages;
|
||||
private Long productQuantity;
|
||||
|
||||
+1
-2
@@ -25,8 +25,7 @@ public interface DispatchWholePdaReadMapper {
|
||||
List<Picked> picked(@Param("ids")List<Long> ids);
|
||||
/** 每条扫码的来源订单、箱数与商品数均须真实完整。 */
|
||||
@Select("<script>SELECT i.dispatch_order_id AS dispatchId,COUNT(*) AS quantity FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id IN "+ROOTS
|
||||
+" AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages"
|
||||
+" OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0)) GROUP BY i.dispatch_order_id</script>")
|
||||
+" AND <![CDATA["+com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.INVALID_PICK+"]]> GROUP BY i.dispatch_order_id</script>")
|
||||
List<Invalid> invalid(@Param("ids")List<Long> ids);
|
||||
/** 仓位先按本批明细客户、实托及绑定仓一次读取。 */
|
||||
@Select("<script>SELECT DISTINCT p.* FROM ll_dispatch_item i JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id WHERE i.dispatch_order_id IN "+ROOTS+" AND p.overseas_warehouse_id=#{warehouse} ORDER BY p.id</script>")
|
||||
|
||||
+35
-21
@@ -18,6 +18,9 @@ import java.time.LocalDateTime;
|
||||
import java.util.TreeMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService;
|
||||
|
||||
@Service
|
||||
public class DispatchPickOrderAttributionService {
|
||||
@@ -25,14 +28,41 @@ public class DispatchPickOrderAttributionService {
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper history;
|
||||
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
|
||||
@Resource private DispatchItemSourceRepository relabelSources;
|
||||
|
||||
/** Acquire source order locks before any inventory lock, matching order hold/release. */
|
||||
/** 普通换标只锁本申请真实履约单,不读取其他来源receipt做FIFO猜测。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void prepareRelabel(String customerId,Long orderId) {
|
||||
var order=orderId==null?null:orders.lockById(customerId,orderId);
|
||||
if(order==null || Boolean.TRUE.equals(order.getDetained()))throw conflict("换标履约订单缺失或暂扣");
|
||||
}
|
||||
|
||||
/** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchItemSourceService.Allocation> allocations) {
|
||||
long packages=0,quantity=0;var seen=new java.util.HashSet<Long>();
|
||||
for(var allocation:allocations) {
|
||||
var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId());
|
||||
if(source==null || !seen.add(source.getId()) || !Objects.equals(source.getCustomerId(),customerId)
|
||||
|| !Objects.equals(source.getDispatchItemId(),pick.getDispatchItemId()) || !Objects.equals(source.getInventoryPositionId(),pick.getInventoryPositionId())
|
||||
|| allocation.packages()<=0 || allocation.productQuantity()!=null && allocation.productQuantity()<0)throw conflict("换标扫码冻结贡献身份不一致");
|
||||
packages=Math.addExact(packages,allocation.packages());quantity=Math.addExact(quantity,allocation.productQuantity()==null?0:allocation.productQuantity());
|
||||
}
|
||||
if(allocations.isEmpty() || packages!=value(pick.getPackages()) || quantity!=value(pick.getProductQuantity()))throw conflict("换标扫码箱件与真实贡献未核平");
|
||||
for(var allocation:allocations) {
|
||||
var row=new DispatchPdaPickOrder();row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setPickId(pick.getId());row.setOrderId(orderId);
|
||||
row.setDispatchItemSourceId(allocation.dispatchItemSourceId());row.setPackages(allocation.packages());row.setProductQuantity(allocation.productQuantity()==null?0:allocation.productQuantity());row.setCreatedAt(pick.getPickedAt());
|
||||
if(attributions.insert(row)!=1)throw conflict("换标实际来源归属未保存");
|
||||
}
|
||||
if(orderId==null || orders.increasePicked(customerId,orderId,packages)!=1)throw conflict("换标履约实际拣货箱数不一致");
|
||||
}
|
||||
|
||||
/** 来源订单先于库存锁,普通实收不补造换标履约归属。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void preparePicking(String customerId, long positionId, Long fallbackOrderId) {
|
||||
public void preparePicking(String customerId, long positionId) {
|
||||
var sourceIds = new java.util.TreeSet<Long>();
|
||||
attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
|
||||
returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
|
||||
if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId);
|
||||
if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order");
|
||||
for (Long orderId : sourceIds) {
|
||||
LastlegOrder order = orders.lockById(customerId, orderId);
|
||||
@@ -50,20 +80,8 @@ public class DispatchPickOrderAttributionService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
|
||||
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
|
||||
return attribute(customerId, pick, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relabel inventory created before receipt-detail backfill may have no
|
||||
* ll_inbound_stock_receipt row. Its dispatch item still carries the
|
||||
* authoritative tail-leg order, so use that order only for relabel picks.
|
||||
*/
|
||||
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) {
|
||||
return attribute(customerId, pick, fallbackOrderId, null);
|
||||
}
|
||||
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) {
|
||||
/** 普通实收与退回实收只按真实receipt归属;换标另走冻结贡献分支。 */
|
||||
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long selectedReturnReceiptId) {
|
||||
long remainingPackages = value(pick.getPackages());
|
||||
long totalPackages = remainingPackages;
|
||||
long totalQuantity = value(pick.getProductQuantity());
|
||||
@@ -114,10 +132,6 @@ public class DispatchPickOrderAttributionService {
|
||||
pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum);
|
||||
remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity;
|
||||
}
|
||||
if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) {
|
||||
pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum);
|
||||
remainingPackages = 0;
|
||||
}
|
||||
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
|
||||
pendingAttributions.forEach(attributions::insert);
|
||||
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
|
||||
|
||||
+1
-10
@@ -25,18 +25,9 @@ public final class DispatchCommands {
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { }
|
||||
|
||||
/** 只关联权威既有预约,确认状态/时间必须解析提供者结果。 */
|
||||
public record AppointmentReference(String referenceNo, Long recordVersion) {
|
||||
/** 拒绝缺引用或缺版本,不提供手工确认状态字段。 */
|
||||
public AppointmentReference {
|
||||
if (referenceNo == null || referenceNo.isBlank()) throw new IllegalArgumentException("预约引用必填");
|
||||
recordVersion = requireVersion(recordVersion);
|
||||
}
|
||||
}
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt,
|
||||
AppointmentReference vehicleAppointment, String remark) {
|
||||
String remark) {
|
||||
/** 缺版本与有效版本零必须区分。 */
|
||||
public Update { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
+7
-1
@@ -59,6 +59,11 @@ public final class DispatchContracts {
|
||||
public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 普通换标本次归属的原货源快照,不以换标履约单或整托货量冒充。 */
|
||||
public record SourceOrigin(Long dispatchItemSourceId, Long relabelApplicationId, Long relabelItemId,
|
||||
Long sourceInventoryBalanceId, Long sourceInboundOrderId,
|
||||
String sourceInboundOrderNo, String sourceInboundCode, Long productQuantity) { }
|
||||
|
||||
/** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */
|
||||
public record CargoLine(Long dispatchItemId, String customerId, String customerName,
|
||||
Long orderId, String orderNo, String sourceKey, String sourceStage,
|
||||
@@ -68,7 +73,8 @@ public final class DispatchContracts {
|
||||
Long receiptId, Long returnReceiptId, Long returnCartonId,
|
||||
String cartonMark, String relabelNo, Destination destination,
|
||||
long packages, long productQuantity, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeCbm, List<BatchAllocation> batchAllocations) { }
|
||||
BigDecimal volumeCbm, List<BatchAllocation> batchAllocations,
|
||||
SourceOrigin sourceOrigin) { }
|
||||
|
||||
/** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */
|
||||
public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo,
|
||||
|
||||
+15
-1
@@ -20,7 +20,21 @@ public final class DispatchPlanningModels {
|
||||
String warehouseName, String orderNo, String inboundCode, String planNo,
|
||||
String palletNo, String containerNo, Destination destination, long availablePackages,
|
||||
BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource,
|
||||
List<BatchReference> appointmentBatches, List<String> blockReasons) { }
|
||||
List<BatchReference> appointmentBatches, List<String> blockReasons,
|
||||
SelectionGroup selectionGroup) { }
|
||||
|
||||
/** 服务端核齐实托与整申请闭包;缺组、缺版本或complete=false均不能作为完整选货证明。 */
|
||||
public record SelectionGroup(String groupKey, String groupVersion, List<SelectionGroupSource> sources,
|
||||
List<SelectionGroupPallet> pallets, boolean complete, List<String> blockReasons) { }
|
||||
|
||||
/** 每个逻辑来源保留真实客户及业务号,selection携带该来源的权威版本。 */
|
||||
public record SelectionGroupSource(DispatchCommands.CargoSelection selection, String sourceKey,
|
||||
String customerId, String customerName, String sourceBusinessType,
|
||||
String sourceBusinessNo, long packages) { }
|
||||
|
||||
/** 实托只列一次;sourceKeys完整列出其逻辑贡献,packageCount为这些贡献的真实合计。 */
|
||||
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
|
||||
long packageCount) { }
|
||||
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
|
||||
|
||||
|
||||
@@ -42,12 +42,9 @@ public final class DispatchViews {
|
||||
String destinationKey, String destinationWarehouseCode,
|
||||
String status, Instant confirmedAt, String blockReason) { }
|
||||
|
||||
/** 仅引用已存在的车辆预约;本批不新增预约权威模型或创建接口。 */
|
||||
public record VehicleAppointment(String referenceNo, String status, Instant appointmentAt) { }
|
||||
|
||||
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
||||
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
||||
VehicleAppointment vehicleAppointment, List<BatchReference> appointmentBatches) { }
|
||||
List<BatchReference> appointmentBatches) { }
|
||||
|
||||
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
|
||||
List<DispatchDeliveryModels.DeliveryReference> deliveries,
|
||||
@@ -55,7 +52,8 @@ public final class DispatchViews {
|
||||
|
||||
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
|
||||
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
|
||||
BigDecimal volumeCbm, String containerNo, String sourceKey) { }
|
||||
BigDecimal volumeCbm, String containerNo, String sourceKey,
|
||||
String sourceBusinessType, String sourceBusinessNo) { }
|
||||
|
||||
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
|
||||
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
|
||||
@@ -70,7 +68,8 @@ public final class DispatchViews {
|
||||
|
||||
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
|
||||
Destination destination, long scheduledPackages, long pickedPackages,
|
||||
List<Position> positions, List<ReturnCarton> returnCartons) { }
|
||||
List<Position> positions, List<ReturnCarton> returnCartons,
|
||||
String sourceKey, String sourceBusinessType, String sourceBusinessNo) { }
|
||||
|
||||
public record Position(Long positionId, Long balanceId, long availablePackages,
|
||||
long allocatedPackages, long pickedPackages) { }
|
||||
|
||||
+3
-3
@@ -43,12 +43,12 @@ public class DispatchDraftRepository {
|
||||
public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");}
|
||||
/** 来源提供者已取根锁后,锁本草稿活动占用。 */
|
||||
public List<DispatchDraftClaimMapper.Claim> claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);}
|
||||
/** 占用来源及实托,两项唯一键共同保证排他。 */
|
||||
/** 逻辑来源引用物理提供者已取得的共享占用,保留本客户独立来源身份。 */
|
||||
public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){
|
||||
if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt())!=1)throw new IllegalStateException("来源占用未保存");
|
||||
if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt(),source.physicalLockId())!=1)throw new IllegalStateException("来源占用未保存");
|
||||
}
|
||||
/** 数据访问输入只包含提供者已经解析的真实标识。 */
|
||||
public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt) { }
|
||||
public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt,Long physicalLockId) { }
|
||||
/** 移动只针对同草稿原容器记录,零行不能被当作成功。 */
|
||||
public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");}
|
||||
/** 释放限定原token、owner及尚未转换的草稿记录。 */
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItemSource;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 换标来源冻结快照的持久化边界,不执行物理库存或履约状态命令。 */
|
||||
@Repository
|
||||
public class DispatchItemSourceRepository {
|
||||
private final DispatchItemSourceMapper mapper;
|
||||
/** 只装配本域真实来源快照Mapper。 */
|
||||
public DispatchItemSourceRepository(DispatchItemSourceMapper mapper){this.mapper=mapper;}
|
||||
/** 唯一贡献键阻止同一item/位置被重复冻结。 */
|
||||
public int insert(DispatchItemSource source){return mapper.insert(source);}
|
||||
/** 批量读取整车真实贡献。 */
|
||||
public List<DispatchItemSource> all(long dispatch){return mapper.all(dispatch);}
|
||||
/** 本批主单的真实贡献一次读取。 */
|
||||
public List<DispatchItemSource> batchAll(List<Long> ids){return ids.isEmpty()?List.of():mapper.batchAll(ids);}
|
||||
/** 本批全部贡献的实际已消费额度一次读取。 */
|
||||
public List<com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper.PickedSource> pickedAll(List<Long> ids){return ids.isEmpty()?List.of():mapper.pickedAll(ids);}
|
||||
/** 新归属分支必须关联真实冻结贡献。 */
|
||||
public DispatchItemSource find(long id){return mapper.selectById(id);}
|
||||
/** 所选位置仍受本客户及本逻辑来源约束。 */
|
||||
public List<DispatchItemSource> position(long dispatch,long item,String customer,long position){return mapper.position(dispatch,item,customer,position);}
|
||||
/** 已消费额度只取本来源不可变归属。 */
|
||||
public long picked(String customer,long source){return mapper.picked(customer,source);}
|
||||
/** 仅删除原主单原明细且尚无扫码的贡献。 */
|
||||
public int removeUnpicked(long dispatch,long item){return mapper.removeUnpicked(dispatch,item);}
|
||||
}
|
||||
+4
-50
@@ -1,60 +1,16 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import java.util.List;
|
||||
|
||||
/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */
|
||||
/** 整车来源规则对原换标申请的事务内数据操作。 */
|
||||
public class DispatchMutationRepository {
|
||||
private final DispatchOrderMapper orders;
|
||||
private final DispatchItemMapper items;
|
||||
private final DispatchPalletLockMapper locks;
|
||||
private final OutboundOrderMapper outbounds;
|
||||
private final DispatchCandidateMapper candidates;
|
||||
private final RelabelApplicationMapper relabels;
|
||||
private final DispatchPdaTaskMapper tasks;
|
||||
|
||||
/** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */
|
||||
public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks,
|
||||
OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) {
|
||||
this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds;
|
||||
this.candidates=candidates; this.relabels=relabels; this.tasks=tasks;
|
||||
}
|
||||
/** 按业务号锁定唯一排车。 */
|
||||
public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); }
|
||||
/** 保存排车的业务变更。 */
|
||||
public void saveOrder(DispatchOrder order) { orders.updateById(order); }
|
||||
/** 创建已校验客户和完整货量的新排车。 */
|
||||
public void createOrder(DispatchOrder order) { orders.insert(order); }
|
||||
/** 读取当前客户排车的原始明细。 */
|
||||
public List<DispatchItem> items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); }
|
||||
/** 锁定本排车的指定托盘明细。 */
|
||||
public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); }
|
||||
/** 保存新换标排车明细。 */
|
||||
public void createItem(DispatchItem item) { items.insert(item); }
|
||||
/** 保存退货换标来源快照。 */
|
||||
public void saveItem(DispatchItem item) { items.updateById(item); }
|
||||
/** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */
|
||||
public void removeItem(Long itemId) { items.deleteById(itemId); }
|
||||
/** 读取该排车自己的活动占用,不触碰后续新排车。 */
|
||||
public List<DispatchPalletLock> activeLocks(DispatchOrder order) {
|
||||
return locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
|
||||
.eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId())
|
||||
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
|
||||
}
|
||||
/** 保存单条原排车占用的释放事实。 */
|
||||
public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); }
|
||||
/** 读取或锁定历史关联出库;业务层区分活动与取消。 */
|
||||
public OutboundOrder outbound(DispatchOrder order, boolean lock) {
|
||||
return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId());
|
||||
public DispatchMutationRepository(DispatchCandidateMapper candidates,RelabelApplicationMapper relabels) {
|
||||
this.candidates=candidates; this.relabels=relabels;
|
||||
}
|
||||
/** 锁定换标申请,串行完整候选检查与整单状态推进。 */
|
||||
public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); }
|
||||
@@ -64,6 +20,4 @@ public class DispatchMutationRepository {
|
||||
public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); }
|
||||
/** 取消未执行排车后恢复原申请候选。 */
|
||||
public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); }
|
||||
/** 创建与新排车同事务的 PDA 任务。 */
|
||||
public void createTask(DispatchPdaTask task) { tasks.insert(task); }
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPhysicalClaimMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 物理占用的持久化适配,拥有者和成员生命周期由服务编排。 */
|
||||
@Repository
|
||||
public class DispatchPhysicalClaimRepository {
|
||||
private final DispatchPhysicalClaimMapper mapper;
|
||||
/** 只经本域Mapper访问物理及其逻辑成员。 */
|
||||
public DispatchPhysicalClaimRepository(DispatchPhysicalClaimMapper mapper){this.mapper=mapper;}
|
||||
/** 锁定唯一活动实托占用。 */
|
||||
public DispatchPhysicalPalletLock lockActive(long pallet){return mapper.lockActive(pallet);}
|
||||
/** 锁定原物理占用记录。 */
|
||||
public DispatchPhysicalPalletLock lockOriginal(long id){return mapper.lockOriginal(id);}
|
||||
/** 原引用集合只读物理身份供服务排序,空集合不生成无效IN。 */
|
||||
public List<DispatchPhysicalPalletLock> originals(List<Long> ids){return ids.isEmpty()?List.of():mapper.originals(ids);}
|
||||
/** 按全局实托顺序锁本主单全部物理占用。 */
|
||||
public List<DispatchPhysicalPalletLock> lockDispatch(long dispatch){return mapper.lockDispatch(dispatch);}
|
||||
/** 按全局实托顺序锁本草稿全部物理占用。 */
|
||||
public List<DispatchPhysicalPalletLock> lockDraft(String token,String operator){return mapper.lockDraft(token,operator);}
|
||||
/** 活动实托唯一键承担并发领取的最终约束。 */
|
||||
public int insert(DispatchPhysicalPalletLock row){return mapper.insert(row);}
|
||||
/** 到期只影响原物理行的草稿成员。 */
|
||||
public int expireMembers(long id,LocalDateTime at){return mapper.expireMembers(id,at);}
|
||||
/** 只有没有活动成员时才结束原物理占用。 */
|
||||
public int releaseUnused(long id,String status,LocalDateTime at){return mapper.releaseUnused(id,status,at);}
|
||||
/** 草稿整托迁移不修改其他拥有者。 */
|
||||
public int moveDraft(long id,String token,String operator,long previous,long next,LocalDateTime at){return mapper.moveDraft(id,token,operator,previous,next,at);}
|
||||
/** 原草稿全部物理占用一起续期。 */
|
||||
public int renewDraft(String token,String operator,LocalDateTime at,LocalDateTime expires){return mapper.renewDraft(token,operator,at,expires);}
|
||||
}
|
||||
+24
-6
@@ -31,6 +31,24 @@ public class DispatchSourceRepository {
|
||||
public List<DispatchPalletCandidate> availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) {
|
||||
return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null);
|
||||
}
|
||||
/** 完整组只扩大到已定位实托的可用普通及真实退回组成。 */
|
||||
public List<DispatchPalletCandidate> physicalAvailable(String warehouse,List<Long> pallets) {
|
||||
if(pallets.isEmpty())return List.of();
|
||||
var result=new java.util.ArrayList<DispatchPalletCandidate>();
|
||||
var selected=source.physicalPlans(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId()));
|
||||
for(var group:selected.entrySet())result.addAll(plans.readSelected(group.getKey(),group.getValue().stream().map(r->r.planPalletId()).toList()));
|
||||
var returned=source.physicalReturns(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId()));
|
||||
for(var group:returned.entrySet())result.addAll(returns.readSelected(group.getKey(),group.getValue().stream().map(r->r.receiptId()).toList()));
|
||||
return result.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).toList();
|
||||
}
|
||||
/** 原生退货箱的真实申请归属只在指定托盘码范围内读取。 */
|
||||
public List<DispatchSourceMapper.NativeApplication> nativeApplications(String warehouse,List<String> codes){return codes.isEmpty()?List.of():source.nativeApplications(warehouse,codes);}
|
||||
/** 指定实托当前余额来源的真实订单集合。 */
|
||||
public List<DispatchSourceMapper.OrderReference> physicalOrders(String warehouse,List<Long> pallets){return pallets.isEmpty()?List.of():source.physicalOrders(warehouse,pallets);}
|
||||
/** 同一全车各逻辑来源已经实际消费的箱数。 */
|
||||
public List<DispatchSourceMapper.ItemPicked> pickedItems(long dispatch){return source.pickedItems(dispatch);}
|
||||
/** 额外身份锁不隐式增加原本没有的资格条件。 */
|
||||
public Long lockOrderIdentity(String customer,long id){return source.lockOrderIdentity(customer,id);}
|
||||
/** 来源托盘的真实客户。 */
|
||||
public List<String> planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); }
|
||||
/** 退回收据的真实客户。 */
|
||||
@@ -58,15 +76,15 @@ public class DispatchSourceRepository {
|
||||
/** 同一实托的退回实收不得从来源证明丢失。 */
|
||||
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
|
||||
/** 释放已到期草稿键。 */
|
||||
public int expire(String customer, String key, Long pallet, LocalDateTime at) { return source.expire(customer,key,pallet,at); }
|
||||
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
||||
/** 锁定当前活动资源占用。 */
|
||||
public List<DispatchSourceMapper.Claim> claims(String customer, String key, Long pallet, LocalDateTime at) { return source.claims(customer,key,pallet,at); }
|
||||
public List<DispatchSourceMapper.Claim> claims(String customer, String key, LocalDateTime at) { return source.claims(customer,key,at); }
|
||||
/** 精确释放本车本来源,不释放他车重新取得的键。 */
|
||||
public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getPlanPalletId(),item.getReturnReceiptId(),item.getWarehousePalletId(),at);}
|
||||
public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getSourceKey(),at);}
|
||||
/** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */
|
||||
public int materialize(long dispatch,String customer,long plan,long pallet,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,at);}
|
||||
public int materialize(long dispatch,String customer,long plan,long pallet,long physical,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,physical,at);}
|
||||
/** 保存正式源/实托占用。 */
|
||||
public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, long dispatch, String operator, LocalDateTime at) {
|
||||
return source.claim(id,customer,key,plan,pallet,receipt,dispatch,operator,at);
|
||||
public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, Long physical, long dispatch, String operator, LocalDateTime at) {
|
||||
return source.claim(id,customer,key,plan,pallet,receipt,physical,dispatch,operator,at);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-31
@@ -32,6 +32,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
@Service
|
||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
/** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */
|
||||
public record SourceDisplay(String containerNo,String planNo) { }
|
||||
private static final String PREFIX = "platform:overseas-warehouse:dispatch:";
|
||||
@@ -103,10 +106,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配");
|
||||
var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet());
|
||||
if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源");
|
||||
var pallets=touched.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
if(items.stream().anyMatch(i->pallets.contains(i.getWarehousePalletId()) && !ids.contains(i.getId())))throw conflict("同实托完整来源须一起调整,不能仅移出部分成员");
|
||||
lockExecutionSources(row,items);
|
||||
appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched);
|
||||
for(var item:touched) {
|
||||
if(sources.release(row.getId(),item,now())<1 || repository.removeItem(item.getId())!=1)throw conflict("原实物占用或货物明细已变化");
|
||||
}
|
||||
releaseSources(row,actor,touched,now());
|
||||
for(var item:touched){relabelSources.removeUnpicked(row.getId(),item.getId());if(repository.removeItem(item.getId())!=1)throw conflict("原货物明细已变化");}
|
||||
refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number);
|
||||
history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
@@ -142,20 +147,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
||||
var groups=sourceRules.selectionGroups(rows);
|
||||
var batchFacts=appointments.candidates(rows);
|
||||
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
||||
for(var row:rows) {
|
||||
SourceType type="RELABEL".equals(row.sourceStage())?SourceType.RELABEL:
|
||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
||||
row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN;
|
||||
String relabel=type==SourceType.RELABEL?row.orderRefs():null;
|
||||
var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null);
|
||||
String key=DispatchService.wholeSourceKey(preliminary),version=sourceRules.wholeSourceVersion(row,key,false);
|
||||
String key=DispatchService.wholeSourceKey(preliminary);var group=groups.get(key);
|
||||
if(group==null)throw conflict("完整来源组缺失,请重新读取");
|
||||
String version=group.sources().stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("本来源不在完整组中")).selection().sourceVersion();
|
||||
var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version);
|
||||
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
|
||||
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
|
||||
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
|
||||
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
|
||||
reasons.addAll(group.blockReasons());
|
||||
if(fact!=null) {
|
||||
reasons.addAll(fact.blockReasons());
|
||||
// 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。
|
||||
@@ -167,7 +176,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(),
|
||||
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
||||
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons)));
|
||||
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
|
||||
}
|
||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
||||
}
|
||||
@@ -214,14 +223,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
|
||||
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
|
||||
}
|
||||
/** 列表权限含详情只读,但文件仍限当前整车复合归属。 */
|
||||
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
|
||||
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File evidenceMetadata(Actor actor,String number,
|
||||
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.metadata(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null)return driverEvidenceMetadata(fleet.dispatchDriverLicense(driverId,fileId).metadata(),fileId);
|
||||
return evidence.metadata(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 内容仅经服务端鉴权代理,不返回匿名存储URL。 */
|
||||
/** 内容沿同一次确定的归属代理读取,不尝试类别回退,不返回匿名存储URL。 */
|
||||
public byte[] evidenceContent(Actor actor,String number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.download(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null) {
|
||||
var file=fleet.dispatchDriverLicense(driverId,fileId);driverEvidenceMetadata(file.metadata(),fileId);return file.content();
|
||||
}
|
||||
return evidence.download(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
|
||||
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
/** 司机域已执行当前/历史关联及File四元组校验,转换元数据时仍保留请求文件身份。 */
|
||||
private static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File driverEvidenceMetadata(
|
||||
com.baiyue.lastleg.fleet.driver.DriverModels.File file,long fileId) {
|
||||
if(file==null || !Long.toString(fileId).equals(file.fileId()))throw bad("司机证照回执文件身份不一致");
|
||||
return new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(fileId,file.fileName(),file.contentType(),file.sizeBytes());
|
||||
}
|
||||
|
||||
/** 空单或同仓多客户选货一次建车,与资源关系、来源锁和账本同事务。 */
|
||||
@@ -290,9 +322,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物");
|
||||
var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在"));
|
||||
if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排");
|
||||
if(selected.getWarehousePalletId()!=null && all.stream().anyMatch(i->!Objects.equals(i.getId(),selected.getId()) && Objects.equals(i.getWarehousePalletId(),selected.getWarehousePalletId())))throw conflict("本实托含多个真实来源,请按完整实托调整或取消后重排");
|
||||
lockExecutionSources(row,all);
|
||||
appointments.lock(repository.batches(row.getId()),List.of());
|
||||
appointments.remove(row.getId(),List.of(selected));
|
||||
if(sources.release(row.getId(),selected,now())<1)throw conflict("原来源占用已变化,未移除货物");
|
||||
releaseSources(row,actor,List.of(selected),now());
|
||||
if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化");
|
||||
refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number);
|
||||
history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId)));
|
||||
@@ -305,9 +339,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
|
||||
if(trim(request.reason())==null)throw bad("取消原因不能为空");
|
||||
if(repository.realPicked(row.getId())!=0 || !repository.outbounds(row.getId()).isEmpty())throw conflict("本车已有真实执行或关联出库,不能取消");
|
||||
var items=repository.cargo(row.getId());appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||
for(var item:items)if(sources.release(row.getId(),item,now())<1)throw conflict("原来源占用已变化,未取消排车");
|
||||
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
|
||||
var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||
releaseSources(row,actor,items,now());
|
||||
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
@@ -320,7 +354,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(request.vehicleAppointment()!=null)throw conflict("车辆预约关联需按权威预约重新核验");
|
||||
String before=basis(row);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
@@ -396,6 +429,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true));
|
||||
}
|
||||
|
||||
/** 新执行命令在原根锁和重放检查之后,锁齐全车真实组成并核对冻结来源。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void lockExecutionSources(DispatchAggregate row,List<DispatchItem> items) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
}
|
||||
|
||||
/** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */
|
||||
@Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public Optional<CommandResult> replay(Context context,Actor actor,CommandIdentity identity) {
|
||||
@@ -467,6 +506,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
|
||||
lockExecutionSources(row,repository.cargo(row.getId()));
|
||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
|
||||
@@ -496,9 +536,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
private Context context(Actor actor,DispatchAggregate row) {
|
||||
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化");
|
||||
var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
|
||||
var batches=repository.batches(row.getId());var cargo=new ArrayList<CargoLine>();
|
||||
var batches=repository.batches(row.getId());var cargo=new ArrayList<CargoLine>();var actualSources=executionReads.sources(row.getId());
|
||||
var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.<Long,com.baiyue.lastleg.dispatch.entity.DispatchItemSource>of();
|
||||
var remaining=new LinkedHashMap<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow,Long>();batches.forEach(b->remaining.put(b,(long)b.packageCount()));
|
||||
for(var pick:executionReads.sources(row.getId())) {
|
||||
for(var pick:actualSources) {
|
||||
var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId();
|
||||
var allocations=new ArrayList<BatchAllocation>();long unassigned=pick.packages();
|
||||
for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) {
|
||||
@@ -506,9 +547,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion()));
|
||||
unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated);
|
||||
}
|
||||
var origin=pick.dispatchItemSourceId()==null?null:origins.get(pick.dispatchItemSourceId());
|
||||
var sourceOrigin=origin==null?null:new SourceOrigin(origin.getId(),origin.getRelabelApplicationId(),origin.getRelabelItemId(),origin.getSourceInventoryBalanceId(),origin.getSourceInboundOrderId(),origin.getSourceInboundOrderNo(),origin.getSourceInboundCode(),origin.getProductQuantity()!=null || pick.productQuantity()>0?pick.productQuantity():null);
|
||||
cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(),
|
||||
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(),
|
||||
"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations));
|
||||
"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations,sourceOrigin));
|
||||
}
|
||||
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||
boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()))
|
||||
@@ -599,7 +642,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,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,null,batchRefs),
|
||||
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
|
||||
documents,orderDetails(cargo));
|
||||
}
|
||||
|
||||
@@ -619,29 +662,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return List.of();
|
||||
}
|
||||
var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet());
|
||||
var physical=current.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
var resolved=sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections);
|
||||
var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current);
|
||||
if(!current.isEmpty())lockExecutionSources(row,current);
|
||||
var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
|
||||
var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at);
|
||||
var saved=new ArrayList<DispatchItem>();
|
||||
for(var source:resolved) {
|
||||
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
|
||||
var c=source.candidate();
|
||||
if(c.warehousePalletId()!=null && !physical.add(c.warehousePalletId()))throw conflict("同一实托不能作为不同来源重复计入本车,请核对真实来源组成");
|
||||
var at=now(); var claims=sources.claims(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at);
|
||||
if(claims.stream().anyMatch(lock -> !Objects.equals(lock.dispatchId(),row.getId()))) throw conflict("所选托盘已被其他有效运输安排占用");
|
||||
if(claims.isEmpty()) {
|
||||
sources.expire(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at);
|
||||
try { if(sources.claim(IdWorker.getId(),c.customerId(),c.sourcePalletKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),row.getId(),actor.operatorId(),at)!=1) throw conflict("来源占用未保存"); }
|
||||
catch(DuplicateKeyException error) { throw conflict("所选实托已被并发排车占用,请重新核对"); }
|
||||
}
|
||||
sources.expire(c.customerId(),source.sourceKey(),at);
|
||||
if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用");
|
||||
try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");}
|
||||
catch(DuplicateKeyException error){throw conflict("所选逻辑来源已被并发排车占用,请重新核对");}
|
||||
var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存");
|
||||
saved.add(item);
|
||||
}
|
||||
var all=new ArrayList<>(current);all.addAll(saved);
|
||||
if(saved.stream().anyMatch(i->"RELABEL".equals(i.getSourceStage()) && "RELABEL_ORDER".equals(i.getSourceBusinessType())))relabelSources.capture(row,all,saved,at);
|
||||
sourceRules.bindWholeRelabel(resolved,saved);
|
||||
appointments.bind(row.getId(),saved,batchAllocations);
|
||||
var all=new ArrayList<>(current); all.addAll(saved);
|
||||
refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList();
|
||||
}
|
||||
|
||||
/** 先锁原物理拥有者,再逐逻辑键释放;同托仍有活动成员时物理排他继续保留。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void releaseSources(DispatchAggregate row,Actor actor,List<DispatchItem> items,LocalDateTime at) {
|
||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
|
||||
var original=physicalClaims.lockOwner(owner);
|
||||
for(var item:items)if(sources.release(row.getId(),item,at)!=1)throw conflict("原逻辑来源占用已变化");
|
||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
||||
}
|
||||
|
||||
/** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */
|
||||
private void refreshCargo(DispatchAggregate row,List<DispatchItem> all) {
|
||||
int packages=0;
|
||||
@@ -754,7 +805,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList();
|
||||
if(orders.isEmpty()) orders=Collections.singletonList(null);
|
||||
for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null,
|
||||
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey()));
|
||||
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo()));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ public class DispatchBolPrintService {
|
||||
List<DispatchBolData.Line> lines = group.getValue();
|
||||
int pages = pageCount(lines.size());
|
||||
int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum();
|
||||
String destination = lines.stream().map(DispatchBolData.Line::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
|
||||
String destination = lines.stream().map(DispatchBolData.Line::destinationLabel).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
|
||||
int offset = 0;
|
||||
for (int page = 0; page < pages; page++) {
|
||||
int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS;
|
||||
|
||||
+2
-1
@@ -42,12 +42,13 @@ public class DispatchDepartureService {
|
||||
if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash())
|
||||
|| !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化");
|
||||
fleet.requireDispatchDeparture(number,confirmed.manifestHash());
|
||||
workflow.lockExecutionSources(row,repository.cargo(row.getId()));
|
||||
var orderOwners=new TreeMap<Long,String>();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId());
|
||||
for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车");
|
||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey());
|
||||
if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平");
|
||||
sourceWorkflow.departWholeSources(context,actor,departed,at);
|
||||
for(var item:repository.cargo(row.getId()))if(sources.release(row.getId(),item,LocalDateTime.ofInstant(at,ZoneOffset.UTC))<1)throw conflict("原发运来源占用已变化");
|
||||
workflow.releaseSources(row,actor,repository.cargo(row.getId()),LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||
fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion();
|
||||
row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||
if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败");
|
||||
|
||||
+152
-28
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -40,19 +41,21 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
private final AppointmentWorkDispatchPort appointmentProvider;
|
||||
private final DispatchPhysicalClaimService physicalClaims;
|
||||
private final PhysicalPalletSerialPort palletSerial;
|
||||
|
||||
/** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules,
|
||||
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider) {
|
||||
this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,Clock.systemUTC());
|
||||
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial) {
|
||||
this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,physicalClaims,palletSerial,Clock.systemUTC());
|
||||
}
|
||||
|
||||
/** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */
|
||||
DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules,
|
||||
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,Clock clock) {
|
||||
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial,Clock clock) {
|
||||
this.repository=repository;this.sources=sources;this.sourceRules=sourceRules;
|
||||
this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.clock=Objects.requireNonNull(clock);
|
||||
this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.physicalClaims=physicalClaims;this.palletSerial=palletSerial;this.clock=Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
/** 候选继续委托主流程已有真实查询和权限范围。 */
|
||||
@@ -88,43 +91,62 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
});
|
||||
}
|
||||
|
||||
/** 来源资格和版本取真实提供者,锁定后才取得共享来源/实托占用。 */
|
||||
/** 单来源请求由服务端扩展完整组,一次命令锁定并加入全部成员及共享物理占用。 */
|
||||
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Draft select(Actor actor,DraftSelection request) {
|
||||
Objects.requireNonNull(request);
|
||||
return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
|
||||
var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source());
|
||||
if(selected.stream().anyMatch(c->c.sourceKey().equals(key)))throw conflict("来源已在本草稿中,请勿重复选择");
|
||||
var group=sourceRules.selectionGroup(row.getWarehouseId(),request.source());
|
||||
var members=groupMembers(group);var requested=members.get(key);
|
||||
if(requested==null || !Objects.equals(requested.selection(),request.source()))throw conflict("来源身份、版本或完整组成已变化");
|
||||
if(selected.stream().anyMatch(c->members.containsKey(c.sourceKey())))throw conflict("完整来源组已全部或部分在本草稿,请重新核对组成");
|
||||
var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId());
|
||||
var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());selections.add(request.source());
|
||||
var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());members.values().stream().map(SelectionGroupSource::selection).forEach(selections::add);
|
||||
var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections);
|
||||
var lockedIndex=new TreeMap<String,DispatchService.WholeSource>();for(var source:locked)if(lockedIndex.putIfAbsent(source.sourceKey(),source)!=null)throw conflict("锁内来源组成重复");
|
||||
if(!lockedIndex.keySet().equals(selectionIndex(selections).keySet()))throw conflict("锁内来源组成员已变化");
|
||||
requireLease(row.getExpiresAt());
|
||||
verifyClaims(row,selected,containers);
|
||||
var resolved=locked.stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("所选来源不存在"));
|
||||
var candidate=candidate(resolved);var next=new ArrayList<>(selected);next.add(candidate);
|
||||
var keys=new ArrayList<>(keys(container));keys.add(key);requireCapacity(container.getCapacity(),keys,index(next));
|
||||
var at=now();sources.expire(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at);
|
||||
if(!sources.claims(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at).isEmpty())throw conflict("所选来源或实托已被其他作业占用");
|
||||
var next=new ArrayList<>(selected);var keys=new ArrayList<>(keys(container));
|
||||
for(var member:members.values()) {
|
||||
var resolved=lockedIndex.get(member.sourceKey());
|
||||
if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId())
|
||||
|| resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化");
|
||||
next.add(candidate(resolved,group));keys.add(member.sourceKey());
|
||||
}
|
||||
requireCapacity(container.getCapacity(),keys,index(next));
|
||||
var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream()
|
||||
.map(member->member.selection().warehousePalletId()).filter(Objects::nonNull).distinct().toList(),at);
|
||||
requireLease(row.getExpiresAt());
|
||||
try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(candidate.customerId(),key,
|
||||
resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId()),at);}
|
||||
catch(DuplicateKeyException error){throw conflict("所选来源或实托已被其他作业占用");}
|
||||
for(var member:members.values()) {
|
||||
var resolved=lockedIndex.get(member.sourceKey());sources.expire(member.customerId(),member.sourceKey(),at);
|
||||
if(!sources.claims(member.customerId(),member.sourceKey(),at).isEmpty())throw conflict("所选来源已被其他作业占用");
|
||||
try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(member.customerId(),member.sourceKey(),
|
||||
resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId(),physical.get(resolved.candidate().warehousePalletId())),at);}
|
||||
catch(DuplicateKeyException error){throw conflict("完整组中的来源已被其他作业占用");}
|
||||
}
|
||||
container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next));
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除只认原容器与原来源完整身份;来源变旧不阻止拥有者释放自己的占用。 */
|
||||
/** 按原冻结完整组原子解除;真实来源变旧不阻止拥有者释放原占用。 */
|
||||
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Draft unselect(Actor actor,DraftSelection request) {
|
||||
Objects.requireNonNull(request);
|
||||
return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
|
||||
var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source());
|
||||
var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致");
|
||||
var container=container(repository.containers(row.getDraftToken()),request.draftContainerId());var keys=new ArrayList<>(keys(container));
|
||||
if(!keys.remove(key))throw conflict("来源不属于本容器");
|
||||
if(repository.release(row,key,container.getId(),now())!=1)throw conflict("原草稿占用已变化");
|
||||
var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId());var keys=new ArrayList<>(keys(container));
|
||||
var members=groupMembers(current.selectionGroup()).keySet();
|
||||
var claims=verifyClaims(row,selected,containers);
|
||||
if(!keys.containsAll(members))throw conflict("完整来源组不属于本容器");
|
||||
var originalPhysical=claims.stream().filter(c->members.contains(c.sourceKey())).map(DispatchDraftClaimMapper.Claim::physicalLockId).filter(Objects::nonNull).distinct().toList();
|
||||
for(var member:members)if(repository.release(row,member,container.getId(),now())!=1)throw conflict("原完整来源组占用已变化");
|
||||
physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),originalPhysical,now());
|
||||
keys.removeAll(members);
|
||||
container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container);
|
||||
row.setSourcesJson(write(selected.stream().filter(c->!c.sourceKey().equals(key)).toList()));
|
||||
row.setSourcesJson(write(selected.stream().filter(c->!members.contains(c.sourceKey())).toList()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,10 +165,18 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());}
|
||||
}
|
||||
if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源");
|
||||
requireWholeLayout(selected,mapping);
|
||||
if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法");
|
||||
sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList());
|
||||
var claims=verifyClaims(row,selected,old);var at=now();
|
||||
for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at);
|
||||
var physicalMoves=new TreeMap<Long,SortedMap<Long,SortedSet<Long>>>();
|
||||
for(var claim:claims)if(claim.physicalLockId()!=null && !Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))
|
||||
physicalMoves.computeIfAbsent(claim.containerId(),ignored->new TreeMap<>())
|
||||
.computeIfAbsent(mapping.get(claim.sourceKey()),ignored->new TreeSet<>()).add(claim.physicalLockId());
|
||||
// 全部逻辑成员先完成布局,再由物理提供者按实际成员目标迁移整托,不能把原容器其他组带走。
|
||||
for(var previous:physicalMoves.entrySet())for(var next:previous.getValue().entrySet())
|
||||
physicalClaims.moveDraft(physicalOwner(row,previous.getKey()),List.copyOf(next.getValue()),next.getKey(),at);
|
||||
repository.stageSequences(row.getDraftToken());
|
||||
for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity());
|
||||
existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);}
|
||||
@@ -170,7 +200,10 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
public Draft release(Actor actor,DraftRelease request) {
|
||||
Objects.requireNonNull(request);
|
||||
return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
|
||||
repository.release(row,null,null,now());row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]");
|
||||
var containers=repository.containers(row.getDraftToken());var claims=verifyClaims(row,selected(row),containers);
|
||||
if(repository.release(row,null,null,now())!=claims.size())throw conflict("草稿来源未完整释放");
|
||||
releasePhysical(row,containers,claims);
|
||||
row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]");
|
||||
for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);}
|
||||
});
|
||||
}
|
||||
@@ -195,12 +228,14 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
requireCapacity(c.getCapacity(),keys(c),index(selected));
|
||||
}
|
||||
if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器");
|
||||
var placement=new HashMap<String,Long>();for(var c:containers)for(var key:keys(c))placement.put(key,c.getId());requireWholeLayout(selected,placement);
|
||||
// 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。
|
||||
aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList());
|
||||
sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList());
|
||||
requireLease(deadline);
|
||||
verifyClaims(row,selected,containers);
|
||||
var claims=verifyClaims(row,selected,containers);
|
||||
if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐");
|
||||
releasePhysical(row,containers,claims);
|
||||
var results=new ArrayList<ConvertedVehicle>();
|
||||
for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch());
|
||||
if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用");
|
||||
@@ -213,11 +248,21 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
/** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */
|
||||
private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer<DispatchDraft> mutation) {
|
||||
platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class);
|
||||
var row=owned(actor,token,version);var deadline=row.getExpiresAt();mutation.accept(row);long before=row.getRecordVersion();
|
||||
var row=owned(actor,token,version);var deadline=row.getExpiresAt();
|
||||
if(Set.of("UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER").contains(action)) {
|
||||
// 纯释放、租约操作使用原冻结身份先取共同串行点,再读逻辑/物理占用;不刷新来源资格。
|
||||
// SELECT/SNAPSHOT 仍由来源提供者按申请、订单→Serial取锁,不能在此提前取得其guard。
|
||||
palletSerial.lock(selected(row).stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).distinct().sorted().toList());
|
||||
requireLease(deadline);
|
||||
}
|
||||
mutation.accept(row);long before=row.getRecordVersion();
|
||||
requireLease(deadline);
|
||||
var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at);
|
||||
if("ACTIVE".equals(row.getStatus())){
|
||||
row.setExpiresAt(at.plusMinutes(30));int renewed=repository.renew(row,at);
|
||||
var previousExpiresAt=row.getExpiresAt();row.setExpiresAt(at.plusMinutes(30));
|
||||
// 先核对并延长原物理租约,再同步逻辑来源与根;同刻操作允许原期限不变。
|
||||
physicalClaims.renewDraft(row.getWarehouseId(),row.getDraftToken(),row.getOperatorId(),at,previousExpiresAt,row.getExpiresAt());
|
||||
int renewed=repository.renew(row,at);
|
||||
requireLease(deadline);
|
||||
var claims=verifyClaims(row,selected(row),repository.containers(token));
|
||||
// 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。
|
||||
@@ -251,20 +296,99 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
var claims=repository.claims(row,now());var mapping=new HashMap<String,Long>();
|
||||
for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器");
|
||||
var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应");
|
||||
requireWholeLayout(selected,mapping);
|
||||
var physical=new HashMap<Long,com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock>();
|
||||
for(var container:containers)for(var lock:physicalClaims.lockOwner(physicalOwner(row,container.getId()))) {
|
||||
if(!Objects.equals(lock.getExpiresAt(),row.getExpiresAt()) || physical.putIfAbsent(lock.getWarehousePalletId(),lock)!=null)
|
||||
throw conflict("草稿物理占用重复或有效期已变化");
|
||||
}
|
||||
var seen=new HashSet<String>();for(var claim:claims){var c=expected.get(claim.sourceKey());
|
||||
if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId())
|
||||
|| !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId())
|
||||
|| !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");}
|
||||
|| !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");
|
||||
var lock=physical.get(claim.palletId());
|
||||
if(claim.palletId()==null?claim.physicalLockId()!=null:lock==null || !Objects.equals(lock.getId(),claim.physicalLockId())
|
||||
|| !Objects.equals(lock.getDraftContainerId(),claim.containerId()))throw conflict("草稿逻辑来源未关联原容器的真实物理占用");
|
||||
}
|
||||
var expectedPallets=selected.stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
|
||||
if(!physical.keySet().equals(expectedPallets))throw conflict("草稿物理占用与完整来源集合不一致");
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** 草稿真实物理拥有者同时由仓、token、容器及操作者限定。 */
|
||||
private DispatchPhysicalClaimService.Owner physicalOwner(DispatchDraft row,long container) {
|
||||
return new DispatchPhysicalClaimService.Owner(row.getWarehouseId(),null,row.getDraftToken(),container,row.getOperatorId(),row.getExpiresAt());
|
||||
}
|
||||
|
||||
/** 逻辑来源全部结束后再按原物理主键释放,不按实托号触碰后续新占用。 */
|
||||
private void releasePhysical(DispatchDraft row,List<DispatchDraftContainer> containers,List<DispatchDraftClaimMapper.Claim> claims) {
|
||||
for(var container:containers) {
|
||||
var ids=claims.stream().filter(c->Objects.equals(c.containerId(),container.getId())).map(DispatchDraftClaimMapper.Claim::physicalLockId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
if(physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),ids,now())!=ids.size())throw conflict("草稿实托还有未结束的逻辑来源");
|
||||
}
|
||||
}
|
||||
|
||||
/** 完整组的身份、版本、逻辑成员和实托贡献均由服务端事实明确给出。 */
|
||||
private static Map<String,SelectionGroupSource> groupMembers(SelectionGroup group) {
|
||||
if(group==null || !group.complete() || group.groupKey()==null || group.groupKey().isBlank()
|
||||
|| group.groupVersion()==null || group.groupVersion().isBlank() || group.sources()==null || group.sources().isEmpty()
|
||||
|| group.pallets()==null)throw conflict("完整来源组尚未核齐,请重新读取候选");
|
||||
var members=new TreeMap<String,SelectionGroupSource>();
|
||||
for(var member:group.sources()) {
|
||||
if(member==null || member.selection()==null || member.customerId()==null || member.customerId().isBlank()
|
||||
|| member.selection().sourceVersion()==null || member.selection().sourceVersion().isBlank() || member.packages()<=0
|
||||
|| !Objects.equals(member.sourceKey(),DispatchService.wholeSourceKey(member.selection()))
|
||||
|| members.putIfAbsent(member.sourceKey(),member)!=null)throw conflict("完整来源组成员身份或版本不完整");
|
||||
}
|
||||
var pallets=new HashSet<Long>();var physicalKeys=new HashSet<String>();
|
||||
for(var pallet:group.pallets()) {
|
||||
if(pallet==null || pallet.warehousePalletId()!=null && (pallet.warehousePalletId()<=0 || !pallets.add(pallet.warehousePalletId()))
|
||||
|| pallet.sourceKeys()==null || pallet.sourceKeys().isEmpty())throw conflict("完整来源组实托身份不完整");
|
||||
if(pallet.warehousePalletId()==null && pallet.sourceKeys().size()!=1)throw conflict("未关联实托的计划必须保留独立真实来源");
|
||||
long packages=0;
|
||||
for(var key:pallet.sourceKeys()) {
|
||||
var member=members.get(key);
|
||||
if(member==null || !physicalKeys.add(key) || !Objects.equals(member.selection().warehousePalletId(),pallet.warehousePalletId()))
|
||||
throw conflict("完整来源组实托与逻辑成员不一致");
|
||||
if(pallet.warehousePalletId()==null && (member.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN
|
||||
|| member.selection().planPalletId()==null || member.selection().planPalletId()<=0))throw conflict("未关联实托的来源缺少真实计划身份");
|
||||
packages=Math.addExact(packages,member.packages());
|
||||
}
|
||||
if(packages!=pallet.packageCount())throw conflict("完整来源组实托箱数未核平");
|
||||
}
|
||||
if(!members.keySet().equals(physicalKeys))throw conflict("完整来源组缺少实际实托或计划成员");
|
||||
return members;
|
||||
}
|
||||
|
||||
/** 任一完整组及同一真实实托只能位于同一容器,不能以布局拆成半托或半申请。 */
|
||||
private static void requireWholeLayout(List<Candidate> selected,Map<String,Long> placement) {
|
||||
var byKey=index(selected);var palletContainers=new HashMap<Long,Long>();
|
||||
for(var candidate:selected) {
|
||||
Long container=placement.get(candidate.sourceKey());if(container==null)throw conflict("完整来源缺少容器");
|
||||
var members=groupMembers(candidate.selectionGroup());
|
||||
if(!members.containsKey(candidate.sourceKey()))throw conflict("草稿来源不在原冻结完整组内");
|
||||
for(var member:members.values()) {
|
||||
var actual=byKey.get(member.sourceKey());
|
||||
if(actual==null || !Objects.equals(actual.selection(),member.selection()) || !Objects.equals(actual.customerId(),member.customerId())
|
||||
|| actual.availablePackages()!=member.packages() || !Objects.equals(actual.sourceVersion(),member.selection().sourceVersion())
|
||||
|| !Objects.equals(container,placement.get(member.sourceKey())))throw conflict("完整来源组不能拆分到不同容器或改变成员版本");
|
||||
}
|
||||
Long pallet=candidate.selection().warehousePalletId();
|
||||
if(pallet!=null) {
|
||||
var previous=palletContainers.putIfAbsent(pallet,container);
|
||||
if(previous!=null && !previous.equals(container))throw conflict("同一真实实托不能拆分到不同容器");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */
|
||||
private Candidate candidate(DispatchService.WholeSource source) {
|
||||
private Candidate candidate(DispatchService.WholeSource source,SelectionGroup group) {
|
||||
var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()),
|
||||
r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone());
|
||||
var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion());
|
||||
var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(),
|
||||
r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of());
|
||||
r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of(),group);
|
||||
return projectCandidate(candidate,readCandidateBatches(List.of(candidate)));
|
||||
}
|
||||
|
||||
@@ -310,7 +434,7 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
|
||||
var refs=result==null?List.<BatchReference>of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(),
|
||||
b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList();
|
||||
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons));
|
||||
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons),c.selectionGroup());
|
||||
}
|
||||
/** 原结果完整保存,异常时随所有业务写入一起回滚。 */
|
||||
private <T>T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 锁内冻结明确组托贡献,后续扫码只消费这些真实额度,不对混合仓位做FIFO猜归属。 */
|
||||
@Service
|
||||
public class DispatchItemSourceService {
|
||||
private final DispatchItemSourceRepository repository;
|
||||
private final RelabelDispatchSourcePort relabel;
|
||||
private final ObjectMapper json;
|
||||
/** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */
|
||||
public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;}
|
||||
|
||||
/** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */
|
||||
public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { }
|
||||
|
||||
/** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void capture(DispatchAggregate root,List<DispatchItem> all,List<DispatchItem> added,LocalDateTime at) {
|
||||
var normal=all.stream().filter(this::normalRelabel).toList();
|
||||
if(normal.isEmpty())return;
|
||||
var snapshot=relabel.lockedSnapshot(root.getOverseasWarehouseId(),normal.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList());
|
||||
if(snapshot==null || !snapshot.complete())throw conflict("换标实托真实来源组成尚未核齐");
|
||||
var selected=new HashMap<String,DispatchItem>();
|
||||
for(var item:normal)if(selected.put(key(item.getSourceBusinessNo(),item.getWarehousePalletId()),item)!=null)throw conflict("本车同一换标来源重复");
|
||||
var applications=snapshot.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a));
|
||||
var positions=snapshot.positions().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Position::positionId,p->p));
|
||||
var totals=new HashMap<Long,Long>();var held=new HashMap<Long,Long>();var identities=new HashSet<String>();
|
||||
var additions=added.stream().map(DispatchItem::getId).collect(Collectors.toSet());
|
||||
for(var source:snapshot.sources()) {
|
||||
var item=selected.get(key(source.relabelNo(),source.warehousePalletId()));var app=applications.get(source.applicationId());var position=positions.get(source.inventoryPositionId());
|
||||
if(item==null)throw conflict("实托或完整换标申请还有未选入本车的实际贡献");
|
||||
if(app==null || position==null || !Objects.equals(app.customerId(),source.customerId()) || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId())
|
||||
|| !Objects.equals(item.getCustomerId(),source.customerId()) || !Objects.equals(root.getOverseasWarehouseId(),source.warehouseId())
|
||||
|| !Objects.equals(position.customerId(),source.customerId()) || !Objects.equals(position.warehousePalletId(),source.warehousePalletId())
|
||||
|| !Objects.equals(position.inventoryBalanceId(),source.inventoryBalanceId()) || source.boundPackageCount()<=0 || source.notifiedPackageCount()<0
|
||||
|| source.productQuantity()!=null && source.productQuantity()<0 || source.bindingBasisHash()==null || !source.bindingBasisHash().matches("[a-f0-9]{64}")
|
||||
|| source.unitFacts()==null)throw conflict("换标原货源、实际位置或绑定依据不完整");
|
||||
if(!identities.add(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId()))throw conflict("换标实际贡献重复,不能重复计算冻结箱数");
|
||||
totals.merge(item.getId(),source.boundPackageCount(),Math::addExact);held.merge(source.inventoryPositionId(),source.boundPackageCount(),Math::addExact);
|
||||
if(!additions.contains(item.getId()))continue;
|
||||
var row=new DispatchItemSource();row.setId(IdWorker.getId());row.setDispatchOrderId(root.getId());row.setDispatchItemId(item.getId());row.setCustomerId(source.customerId());
|
||||
if(app.roundKey()==null || app.roundKey().isBlank())throw conflict("换标实际作业轮次缺失");
|
||||
row.setRelabelApplicationId(source.applicationId());row.setRelabelItemId(source.itemId());row.setRelabelRoundKey(app.roundKey());row.setWarehousePalletId(source.warehousePalletId());
|
||||
row.setInventoryPositionId(source.inventoryPositionId());row.setInventoryBalanceId(source.inventoryBalanceId());row.setSourceInventoryBalanceId(source.sourceInventoryBalanceId());
|
||||
row.setSourceInboundOrderId(source.sourceInboundOrderId());row.setSourceInboundOrderNo(source.sourceInboundOrderNo());row.setSourceInboundCode(source.sourceInboundCode());
|
||||
row.setNotifiedPackageCount(source.notifiedPackageCount());row.setPackageCount(source.boundPackageCount());row.setProductQuantity(source.productQuantity());
|
||||
row.setBindingBasisHash(source.bindingBasisHash());row.setUnitFactsJson(write(source.unitFacts()));row.setCreatedAt(at);
|
||||
if(repository.insert(row)!=1)throw conflict("换标实际贡献未保存");
|
||||
}
|
||||
for(var item:normal)if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("本换标来源箱数与逐项实际贡献不一致");
|
||||
for(var position:snapshot.positions())if(held.getOrDefault(position.positionId(),0L)!=position.holdPackages())throw conflict("实托累计换标贡献与真实冻结箱数不一致");
|
||||
}
|
||||
|
||||
/** 正常扫码可改变状态和库存版本,但不得改变轮次、原货源、位置或逐件作业事实。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void validateFrozen(DispatchAggregate root,List<DispatchItem> items,RelabelDispatchSourcePort.Snapshot current) {
|
||||
var normal=items.stream().filter(this::normalRelabel).collect(Collectors.toMap(DispatchItem::getId,i->i));
|
||||
var nativeNos=items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet());
|
||||
var frozen=repository.all(root.getId());var expected=new HashMap<String,DispatchItemSource>();
|
||||
for(var row:frozen) {
|
||||
var item=normal.get(row.getDispatchItemId());
|
||||
if(item==null || !Objects.equals(item.getCustomerId(),row.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),row.getWarehousePalletId()))throw conflict("本车冻结贡献与逻辑来源不一致");
|
||||
if(expected.put(row.getRelabelApplicationId()+":"+row.getRelabelItemId()+":"+row.getInventoryPositionId(),row)!=null)throw conflict("本车冻结贡献身份重复");
|
||||
}
|
||||
var apps=current.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a));
|
||||
for(var source:current.sources()) {
|
||||
if(nativeNos.contains(source.relabelNo()))continue;
|
||||
var row=expected.remove(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId());
|
||||
if(row==null)throw conflict("实托出现未经本车审核的换标贡献");
|
||||
var app=apps.get(source.applicationId());var item=normal.get(row.getDispatchItemId());
|
||||
if(app==null || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId()) || !Objects.equals(app.relabelNo(),item.getSourceBusinessNo())
|
||||
|| !Objects.equals(app.roundKey(),row.getRelabelRoundKey()) || !"READY_FOR_OUTBOUND".equals(app.status())
|
||||
|| !"PRE_DISPATCH_CREATED".equals(app.fulfillmentStatus()) || !"TRUCK".equals(app.deliveryMethod())
|
||||
|| !Objects.equals(source.customerId(),row.getCustomerId()) || !Objects.equals(source.warehouseId(),root.getOverseasWarehouseId())
|
||||
|| !Objects.equals(source.warehousePalletId(),row.getWarehousePalletId()) || !Objects.equals(source.inventoryBalanceId(),row.getInventoryBalanceId())
|
||||
|| !Objects.equals(source.sourceInventoryBalanceId(),row.getSourceInventoryBalanceId()) || !Objects.equals(source.sourceInboundOrderId(),row.getSourceInboundOrderId())
|
||||
|| !Objects.equals(source.sourceInboundOrderNo(),row.getSourceInboundOrderNo()) || !Objects.equals(source.sourceInboundCode(),row.getSourceInboundCode())
|
||||
|| source.notifiedPackageCount()!=row.getNotifiedPackageCount() || !Objects.equals(source.productQuantity(),row.getProductQuantity())
|
||||
|| !sameFacts(source.unitFacts(),row.getUnitFactsJson()))throw conflict("换标轮次、实际位置或原货源依据已变化,请回平台核对");
|
||||
}
|
||||
if(!expected.isEmpty() || !normal.isEmpty() && frozen.isEmpty())throw conflict("本车原冻结换标贡献缺失");
|
||||
var totals=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId,Collectors.summingLong(DispatchItemSource::getPackageCount)));
|
||||
for(var item:normal.values())if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("换标逐项冻结箱数尚未核平");
|
||||
var used=repository.pickedAll(List.of(root.getId())).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages()));
|
||||
var remaining=new HashMap<Long,Long>();
|
||||
for(var row:frozen) {
|
||||
long left=row.getPackageCount()-used.getOrDefault(row.getId(),0L);
|
||||
if(left<0)throw conflict("换标实际消费已超过冻结贡献");remaining.merge(row.getInventoryPositionId(),left,Math::addExact);
|
||||
}
|
||||
for(var position:current.positions())if(position.holdPackages()!=remaining.getOrDefault(position.positionId(),0L))throw conflict("实托全部换标剩余贡献与实际冻结箱数不一致");
|
||||
}
|
||||
|
||||
/** 比较原逐件事实,不把JSON排版当作来源变化。 */
|
||||
private boolean sameFacts(Object facts,String frozen){try{return json.readTree(write(facts)).equals(json.readTree(frozen));}catch(Exception error){throw new IllegalStateException("冻结逐件事实无法读取",error);}}
|
||||
|
||||
/** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public List<Allocation> allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List<DispatchSourceMapper.Stock> actualStock) {
|
||||
var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p));
|
||||
var expected=new HashMap<Long,Long>();
|
||||
for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) {
|
||||
long picked=consumed.containsKey(source.getId())?consumed.get(source.getId()).packages():0;
|
||||
if(picked<0 || picked>source.getPackageCount())throw conflict("换标实际消费额度已超过冻结贡献");
|
||||
expected.merge(source.getInventoryPositionId(),source.getPackageCount()-picked,Math::addExact);
|
||||
}
|
||||
var stock=actualStock.stream().collect(Collectors.toMap(DispatchSourceMapper.Stock::id,s->s));
|
||||
for(var actual:actualStock)if(actual.holdPackages()!=expected.getOrDefault(actual.id(),0L))throw conflict("同实托累计换标剩余份额与实际冻结箱数不一致");
|
||||
for(var entry:expected.entrySet())if(entry.getValue()>0 && !stock.containsKey(entry.getKey()))throw conflict("换标实际来源位置已变化");
|
||||
var rows=all.stream().filter(s->Objects.equals(item.getId(),s.getDispatchItemId()) && Objects.equals(item.getCustomerId(),s.getCustomerId())
|
||||
&& Objects.equals(positionId,s.getInventoryPositionId())).filter(s->s.getPackageCount()>(consumed.containsKey(s.getId())?consumed.get(s.getId()).packages():0))
|
||||
.filter(s->cartonMark==null || cartonMark.isBlank() || matches(s,cartonMark.trim())).toList();
|
||||
if(rows.isEmpty() || !stock.containsKey(positionId) || rows.stream().anyMatch(s->!Objects.equals(stock.get(positionId).balanceId(),s.getInventoryBalanceId())))throw conflict("所选位置或箱唛不属于本换标来源的剩余贡献");
|
||||
var remaining=new HashMap<Long,Long>();long total=0;
|
||||
for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);}
|
||||
if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数");
|
||||
if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数");
|
||||
var result=new ArrayList<Allocation>();long knownQuantity=0;
|
||||
for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);}
|
||||
if(rows.size()>1) {
|
||||
if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数");
|
||||
for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));}
|
||||
} else {
|
||||
var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致");
|
||||
result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 移货只删除未产生实际拣货的原贡献;取消保留快照作历史。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void removeUnpicked(long dispatch,long item){repository.removeUnpicked(dispatch,item);}
|
||||
/** 普通换标和原生退货箱始终使用不同真实来源分支。 */
|
||||
private boolean normalRelabel(DispatchItem item){return "RELABEL_ORDER".equals(item.getSourceBusinessType()) && "RELABEL".equals(item.getSourceStage());}
|
||||
/** 组内身份由真实换标业务号与全局实托共同确定。 */
|
||||
private String key(String number,Long pallet){return number+":"+pallet;}
|
||||
/** 箱唛只在所选应用/位置范围内解析,不用另一个应用的同托记录冒充。 */
|
||||
private boolean matches(DispatchItemSource source,String mark){
|
||||
try {for(var fact:json.readTree(source.getUnitFactsJson()))if(mark.equalsIgnoreCase(fact.path("oldLabel").asText()) || mark.equalsIgnoreCase(fact.path("newLabel").asText()))return true;return false;}
|
||||
catch(Exception failure){throw new IllegalStateException("冻结换标工作事实无法读取",failure);}
|
||||
}
|
||||
/** 原工作事实完整持久化,不把成功数或SKU件数改写成通知箱数。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception failure){throw new IllegalStateException("换标实际贡献无法保存",failure);}}
|
||||
/** 任一来源不一致均由原主单事务整体回滚。 */
|
||||
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
|
||||
}
|
||||
+38
-4
@@ -15,6 +15,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
/** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */
|
||||
@Service
|
||||
public class DispatchPdaQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||
@@ -35,20 +36,51 @@ public class DispatchPdaQueryService {
|
||||
var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity()));
|
||||
var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId()));
|
||||
var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList<DispatchViews.PdaTask>();
|
||||
boolean hasRelabel=cargo.values().stream().flatMap(Collection::stream).anyMatch(DispatchPdaQueryService::normalRelabel);
|
||||
var frozen=hasRelabel?sourceOrigins.batchAll(ids):List.<DispatchItemSource>of();
|
||||
var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId));
|
||||
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
|
||||
for(var root:roots) {
|
||||
var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList<DispatchViews.PdaItem>();
|
||||
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
|
||||
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
|
||||
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
|
||||
if(used<0 || left<0)sourceValid=false;
|
||||
expectedHold.merge(source.getCustomerId()+":"+source.getWarehousePalletId()+":"+source.getInventoryPositionId(),left,Math::addExact);
|
||||
}
|
||||
if(Stage.LOADING.name().equals(root.getStage())) {
|
||||
for(var group:positions.values())for(var p:group) {
|
||||
String key=p.getCustomerId()+":"+p.getPalletId()+":"+p.getId();
|
||||
if(items.stream().anyMatch(i->normalRelabel(i) && Objects.equals(i.getCustomerId(),p.getCustomerId()) && Objects.equals(i.getWarehousePalletId(),p.getPalletId()))
|
||||
&& n(p.getHoldPackages())!=expectedHold.getOrDefault(key,0L))sourceValid=false;
|
||||
}
|
||||
}
|
||||
for(var item:items) {
|
||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||
boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType());
|
||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages())+(relabel?n(p.getHoldPackages()):0),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
if(normalRelabel(item)) {
|
||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||
for(var group:contributions.stream().collect(Collectors.groupingBy(DispatchItemSource::getInventoryPositionId,TreeMap::new,Collectors.toList())).entrySet()) {
|
||||
long used=group.getValue().stream().mapToLong(s->consumed.getOrDefault(s.getId(),0L)).sum();
|
||||
long total=group.getValue().stream().mapToLong(s->n(s.getPackageCount())).sum();
|
||||
var actual=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream().filter(p->Objects.equals(p.getId(),group.getKey())).findFirst().orElse(null);
|
||||
if(actual==null || group.getValue().stream().anyMatch(s->!Objects.equals(s.getCustomerId(),item.getCustomerId()) || !Objects.equals(s.getWarehousePalletId(),item.getWarehousePalletId()) || !Objects.equals(s.getInventoryBalanceId(),actual.getBalanceId()))) {
|
||||
if(total>used)sourceValid=false;continue;
|
||||
}
|
||||
ownPositions.add(new DispatchViews.Position(actual.getId(),actual.getBalanceId(),Math.max(0,total-used),0,used));
|
||||
}
|
||||
sourcePositions=List.copyOf(ownPositions);
|
||||
}
|
||||
var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark()))
|
||||
.map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList();
|
||||
detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons));
|
||||
detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo()));
|
||||
}
|
||||
boolean complete=n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0
|
||||
boolean complete=sourceValid && n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0
|
||||
&& !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()));
|
||||
var actions=new EnumMap<Action,Gate>(workflow.actions(actor,root));
|
||||
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
||||
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
@@ -61,4 +93,6 @@ public class DispatchPdaQueryService {
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
private static long n(Number value){return value==null?0:value.longValue();}
|
||||
/** 原生退货箱沿真实箱账本,不把它当普通仓位贡献。 */
|
||||
private static boolean normalRelabel(DispatchItem item){return "RELABEL".equals(item.getSourceStage()) && "RELABEL_ORDER".equals(item.getSourceBusinessType());}
|
||||
}
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchPhysicalClaimRepository;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 同托成员共享一个真实拥有者;所有方法跟随主单或草稿原事务,不独立提交。 */
|
||||
@Service
|
||||
public class DispatchPhysicalClaimService {
|
||||
private final DispatchPhysicalClaimRepository repository;
|
||||
/** 物理占用不依赖任何客户代表行。 */
|
||||
public DispatchPhysicalClaimService(DispatchPhysicalClaimRepository repository){this.repository=repository;}
|
||||
|
||||
/** 正式拥有者和草稿拥有者互斥,草稿身份还必须包含原容器和原操作人。 */
|
||||
public record Owner(String warehouseId,Long dispatchId,String draftToken,Long draftContainerId,String operatorId,LocalDateTime expiresAt) {
|
||||
/** 不允许混合或缺失拥有者字段,空计划来源不会申请物理占用。 */
|
||||
public Owner {
|
||||
if(warehouseId==null || warehouseId.isBlank() || operatorId==null || operatorId.isBlank()
|
||||
|| !(dispatchId!=null && dispatchId>0 && draftToken==null && draftContainerId==null && expiresAt==null
|
||||
|| dispatchId==null && draftToken!=null && !draftToken.isBlank() && draftContainerId!=null && draftContainerId>0 && expiresAt!=null))
|
||||
throw new IllegalArgumentException("物理实托拥有者身份不完整");
|
||||
}
|
||||
}
|
||||
|
||||
/** 调用方先锁齐申请、来源及库存,再按实托全局顺序取得排他占用。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public Map<Long,Long> reserve(Owner owner,Collection<Long> pallets,LocalDateTime at) {
|
||||
if(at==null || owner.expiresAt()!=null && !owner.expiresAt().isAfter(at))throw conflict("草稿占用已到期,请重新核对");
|
||||
var result=new LinkedHashMap<Long,Long>();
|
||||
for(var pallet:ordered(pallets)) {
|
||||
var row=repository.lockActive(pallet);
|
||||
if(row!=null && !Objects.equals(row.getWarehouseId(),owner.warehouseId()))throw conflict("实托占用起运仓不一致,请核对来源");
|
||||
if(row!=null && row.getDispatchOrderId()==null && row.getExpiresAt()!=null && !row.getExpiresAt().isAfter(at)) {
|
||||
repository.expireMembers(row.getId(),at);
|
||||
if(repository.releaseUnused(row.getId(),"EXPIRED",at)!=1)throw conflict("原实托成员尚未完整到期");
|
||||
row=null;
|
||||
}
|
||||
if(row!=null) {
|
||||
if(!owns(row,owner))throw conflict("所选实托已被其他有效运输安排占用");
|
||||
} else {
|
||||
row=new DispatchPhysicalPalletLock();row.setId(IdWorker.getId());row.setWarehouseId(owner.warehouseId());
|
||||
row.setWarehousePalletId(pallet);row.setActiveWarehousePalletId(pallet);row.setDispatchOrderId(owner.dispatchId());
|
||||
row.setDraftToken(owner.draftToken());row.setDraftContainerId(owner.draftContainerId());row.setOperatorId(owner.operatorId());
|
||||
row.setStatus("ACTIVE");row.setExpiresAt(owner.expiresAt());row.setLockedAt(at);row.setCreatedAt(at);row.setUpdatedAt(at);
|
||||
try{if(repository.insert(row)!=1)throw conflict("实托占用未保存");}
|
||||
catch(DuplicateKeyException error){throw conflict("所选实托已被并发运输安排占用,请重新核对");}
|
||||
}
|
||||
result.put(pallet,row.getId());
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** 只结束原主键、本拥有者且没有活动成员的占用;其他来源仍在时保留物理排他。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public int releaseIfUnused(Owner owner,Collection<Long> originalIds,LocalDateTime at) {
|
||||
Objects.requireNonNull(at,"释放时间缺失");
|
||||
var original=new ArrayList<DispatchPhysicalPalletLock>();
|
||||
// 先普通读取不可变实托身份,再按实托逐原主键取锁,不依赖ORDER BY之前的数据库扫描锁序。
|
||||
var references=repository.originals(ordered(originalIds)).stream()
|
||||
.sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList();
|
||||
for(var ref:references) {
|
||||
var row=repository.lockOriginal(ref.getId());
|
||||
if(row==null || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原物理占用身份已变化");
|
||||
if("ACTIVE".equals(row.getStatus()) && owns(row,owner))original.add(row);
|
||||
}
|
||||
int released=0;
|
||||
for(var row:original)released+=repository.releaseUnused(row.getId(),"RELEASED",at);
|
||||
return released;
|
||||
}
|
||||
|
||||
/** 按拥有者普通读原身份,再按实际实托逐主键取锁,不依赖批量扫描计划的加锁先后。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public List<DispatchPhysicalPalletLock> lockOwner(Owner owner) {
|
||||
var references=owner.dispatchId()!=null?repository.lockDispatch(owner.dispatchId()):repository.lockDraft(owner.draftToken(),owner.operatorId());
|
||||
var result=new ArrayList<DispatchPhysicalPalletLock>();
|
||||
for(var ref:references.stream().filter(r->owner.dispatchId()!=null || Objects.equals(r.getDraftContainerId(),owner.draftContainerId()))
|
||||
.sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList()) {
|
||||
var row=repository.lockOriginal(ref.getId());
|
||||
if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,owner) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原实托占用归属已变化");
|
||||
result.add(row);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 逻辑成员须已全部移入目标容器;物理拥有者仍按原容器及原租约核对。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void moveDraft(Owner previous,Collection<Long> originalPhysicalLockIds,long nextContainerId,LocalDateTime at) {
|
||||
if(previous.dispatchId()!=null || nextContainerId<=0 || at==null || !previous.expiresAt().isAfter(at))throw conflict("草稿整托迁移身份或租约无效");
|
||||
var ids=ordered(originalPhysicalLockIds);var references=repository.originals(ids).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList();
|
||||
if(ids.isEmpty() || references.size()!=ids.size())throw conflict("原草稿容器没有完整可迁移的实托占用");
|
||||
var rows=new ArrayList<DispatchPhysicalPalletLock>();
|
||||
for(var ref:references) {
|
||||
var row=repository.lockOriginal(ref.getId());
|
||||
if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,previous) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原草稿实托拥有者已变化");
|
||||
rows.add(row);
|
||||
}
|
||||
for(var row:rows) {
|
||||
if(!Objects.equals(row.getExpiresAt(),previous.expiresAt()))throw conflict("原草稿实托租约已变化");
|
||||
if(repository.moveDraft(row.getId(),previous.draftToken(),previous.operatorId(),previous.draftContainerId(),nextContainerId,at)!=1)throw conflict("同实托全部逻辑来源尚未移入同一目标容器");
|
||||
}
|
||||
}
|
||||
|
||||
/** 调用方先持草稿根和完整共同实托锁,物理续期后再同事务续期逻辑成员及根。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void renewDraft(String warehouseId,String draftToken,String operatorId,LocalDateTime at,LocalDateTime previousExpiresAt,LocalDateTime nextExpiresAt) {
|
||||
if(warehouseId==null || warehouseId.isBlank() || draftToken==null || draftToken.isBlank() || operatorId==null || operatorId.isBlank()
|
||||
|| at==null || previousExpiresAt==null || nextExpiresAt==null || !previousExpiresAt.isAfter(at) || nextExpiresAt.isBefore(previousExpiresAt))throw conflict("草稿续期身份或租约无效");
|
||||
var references=repository.lockDraft(draftToken,operatorId).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList();
|
||||
for(var ref:references) {
|
||||
var row=repository.lockOriginal(ref.getId());
|
||||
if(row==null || !"ACTIVE".equals(row.getStatus()) || row.getDispatchOrderId()!=null || row.getDraftContainerId()==null
|
||||
|| !Objects.equals(row.getWarehouseId(),warehouseId) || !Objects.equals(row.getDraftToken(),draftToken) || !Objects.equals(row.getOperatorId(),operatorId)
|
||||
|| !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()) || !Objects.equals(row.getExpiresAt(),previousExpiresAt))throw conflict("草稿实托归属或原租约已变化");
|
||||
}
|
||||
if(nextExpiresAt.equals(previousExpiresAt))return;
|
||||
if(repository.renewDraft(draftToken,operatorId,at,nextExpiresAt)!=references.size())throw conflict("草稿实托续期未完整保存");
|
||||
}
|
||||
|
||||
/** 身份核对不把正式排车创建操作人当拥有者,其他有权限人员仍可维护同一主单。 */
|
||||
private boolean owns(DispatchPhysicalPalletLock row,Owner owner) {
|
||||
return Objects.equals(row.getWarehouseId(),owner.warehouseId()) && Objects.equals(row.getDispatchOrderId(),owner.dispatchId())
|
||||
&& Objects.equals(row.getDraftToken(),owner.draftToken()) && Objects.equals(row.getDraftContainerId(),owner.draftContainerId())
|
||||
&& (owner.dispatchId()!=null || Objects.equals(row.getOperatorId(),owner.operatorId()));
|
||||
}
|
||||
|
||||
/** 实托和原占用引用必须真实且去重,固定顺序避免逐客户交错取锁。 */
|
||||
private List<Long> ordered(Collection<Long> ids) {
|
||||
if(ids==null || ids.stream().anyMatch(id->id==null || id<=0))throw new IllegalArgumentException("真实实托或占用引用缺失");
|
||||
return List.copyOf(new TreeSet<>(ids));
|
||||
}
|
||||
/** 任一物理争用失败都由外层事务回滚整组逻辑来源。 */
|
||||
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
|
||||
}
|
||||
+34
-15
@@ -24,6 +24,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
||||
@Service
|
||||
public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -45,9 +48,10 @@ public class DispatchPickingService {
|
||||
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
||||
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
|
||||
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
|
||||
var item=repository.cargo(row.getId()).stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
||||
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
|
||||
materialize(row.getId(),item);
|
||||
materialize(row,item,actor,currentItems);
|
||||
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
|
||||
if(task==null) {
|
||||
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
|
||||
@@ -60,19 +64,22 @@ public class DispatchPickingService {
|
||||
if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数");
|
||||
boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage());
|
||||
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
|
||||
long pickId=IdWorker.getId();Long returnCartonId=null;
|
||||
long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType());
|
||||
List<DispatchItemSourceService.Allocation> relabelAllocations=List.of();
|
||||
if(nativeReturn) {
|
||||
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
|
||||
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
|
||||
} else {
|
||||
var position=picking.position(request.positionId());
|
||||
if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托");
|
||||
Long fallback="RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null;
|
||||
attribution.preparePicking(item.getCustomerId(),request.positionId(),fallback);
|
||||
var actual=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true).stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动"));
|
||||
if(ordinaryRelabel)attribution.prepareRelabel(item.getCustomerId(),item.getOrderId());
|
||||
else attribution.preparePicking(item.getCustomerId(),request.positionId());
|
||||
var stock=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true);
|
||||
var actual=stock.stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动"));
|
||||
if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化");
|
||||
if(ordinaryRelabel)relabelAllocations=relabelSources.allocate(row.getId(),item,request.positionId(),request.packages(),request.productQuantity(),request.cartonMark(),stock);
|
||||
String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey();
|
||||
if(fallback!=null)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE"));
|
||||
if(ordinaryRelabel)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE"));
|
||||
inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE"));
|
||||
inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK"));
|
||||
inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP"));
|
||||
@@ -81,9 +88,12 @@ public class DispatchPickingService {
|
||||
pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at);
|
||||
if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存");
|
||||
if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId());
|
||||
else attribution.attribute(item.getCustomerId(),pick,"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null,item.getReturnReceiptId());
|
||||
else if(ordinaryRelabel)attribution.attributeRelabel(item.getCustomerId(),pick,item.getOrderId(),relabelAllocations);
|
||||
else attribution.attribute(item.getCustomerId(),pick,item.getReturnReceiptId());
|
||||
task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存");
|
||||
long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));
|
||||
row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败");
|
||||
boolean complete=workflow.preview(actor,number).pickingReconciled();
|
||||
if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存");
|
||||
@@ -92,22 +102,31 @@ public class DispatchPickingService {
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
|
||||
private void materialize(long dispatch,DispatchItem item) {
|
||||
if(item.getWarehousePalletId()!=null)return;
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
|
||||
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
|
||||
for(var id:orders)if(sources.lockOrder(item.getCustomerId(),id)==null)throw conflict("原计划订单已变化或暂扣");
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
|
||||
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
|
||||
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
|
||||
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC))!=1)throw conflict("原计划来源占用已变化");}
|
||||
item.setWarehousePalletId(actual.warehousePalletId());
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对");
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true);
|
||||
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId());
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId());
|
||||
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
|
||||
item.setWarehousePalletId(actual.warehousePalletId());item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
|
||||
+267
-489
@@ -1,55 +1,154 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
|
||||
@Service
|
||||
public class DispatchService {
|
||||
@Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository wholeSources;
|
||||
@Resource private DispatchCommandHasher wholeHashes;
|
||||
@Resource private RelabelDispatchSourcePort relabelSources;
|
||||
@Resource private com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial;
|
||||
private final Object relabelLockScope=new Object();
|
||||
|
||||
/** 候选与写入共用完整来源集合,展示筛选不参与组成推导。 */
|
||||
private record SourceGroup(SelectionGroup view,Map<String,DispatchPalletCandidate> rows,Map<String,String> versions) { }
|
||||
|
||||
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
||||
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
||||
var result=new LinkedHashMap<String,SelectionGroup>();
|
||||
for(var row:rows) {
|
||||
String key=wholeSourceKey(selection(row,null));
|
||||
if(result.containsKey(key))continue;
|
||||
var group=sourceGroup(row,false);
|
||||
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
|
||||
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
||||
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
||||
}
|
||||
|
||||
/** 业务来源和物理托盘分别标识;来源版本由同一完整组事实派生。 */
|
||||
private DispatchCommands.CargoSelection selection(DispatchPalletCandidate row,String version) {
|
||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN;
|
||||
return new DispatchCommands.CargoSelection(type,type==SourceType.INBOUND_PLAN?row.planPalletId():null,type==SourceType.RETURN_RECEIPT?row.returnReceiptId():null,
|
||||
type==SourceType.RELABEL?row.orderRefs():null,row.warehousePalletId(),version);
|
||||
}
|
||||
|
||||
/** 只读定位请求真实来源,不在收齐申请/订单之前抢占任何库存锁。 */
|
||||
private DispatchPalletCandidate readSelection(String warehouse,DispatchCommands.CargoSelection request) {
|
||||
wholeSourceKey(request);DispatchPalletCandidate row;
|
||||
if(request.sourceType()==SourceType.INBOUND_PLAN)row=requireWholeCandidate(wholeSources.selectedPlan(requireSingleCustomerScope(wholeSources.planOwners(request.planPalletId()),"所选来源"),request.planPalletId()));
|
||||
else if(request.sourceType()==SourceType.RETURN_RECEIPT)row=requireWholeCandidate(wholeSources.selectedReturn(requireSingleCustomerScope(wholeSources.returnOwners(request.returnReceiptId()),"所选退回来源"),request.returnReceiptId()));
|
||||
else {
|
||||
String customer=wholeSources.relabelOwner(request.relabelNo());
|
||||
if(customer==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"换标来源不存在");
|
||||
row=relabelRows(customer,request.relabelNo()).stream().filter(r->Objects.equals(request.warehousePalletId(),r.warehousePalletId())).findFirst()
|
||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化"));
|
||||
}
|
||||
if(!Objects.equals(warehouse,row.overseasWarehouseId()))throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源起运仓不一致");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 普通和原生退货换标仍各走实际生产者,保留整申请全部应排托盘。 */
|
||||
private List<DispatchPalletCandidate> relabelRows(String customer,String no) {
|
||||
return java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer,no)).stream(),returnRelabelStock.candidates(customer,null,no).stream()).toList();
|
||||
}
|
||||
|
||||
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
||||
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
|
||||
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
|
||||
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
|
||||
if(selection(seed,null).sourceType()==SourceType.RELABEL)applications.put(seed.orderRefs(),seed.customerId());
|
||||
RelabelDispatchSourcePort.Snapshot facts=null;var previous=new TreeSet<Long>();
|
||||
do {
|
||||
previous.clear();previous.addAll(pallets);
|
||||
if(!pallets.isEmpty()) {
|
||||
facts=locked?relabelSources.lockedSnapshot(seed.overseasWarehouseId(),List.copyOf(pallets)):relabelSources.snapshot(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||
reasons.addAll(facts.blockReasons());
|
||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
|
||||
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
||||
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
||||
}
|
||||
for(var app:new TreeMap<>(applications).entrySet())if(loaded.add(app.getKey())) {
|
||||
var complete=relabelRows(app.getValue(),app.getKey());
|
||||
if(complete.isEmpty())reasons.add(app.getKey()+":申请尚无完整可排实托来源");
|
||||
for(var row:complete) {
|
||||
if(!Objects.equals(seed.overseasWarehouseId(),row.overseasWarehouseId())){reasons.add("完整换标申请包含其他起运仓");continue;}
|
||||
rows.put(wholeSourceKey(selection(row,null)),row);if(row.warehousePalletId()!=null)pallets.add(row.warehousePalletId());
|
||||
}
|
||||
}
|
||||
if(rows.size()>1000 || pallets.size()>1000)throw new ResponseStatusException(HttpStatus.CONFLICT,"完整来源组超过完整核验上限");
|
||||
} while(!pallets.equals(previous));
|
||||
var nativeNos=rows.values().stream().filter(r->"RETURN_RELABEL".equals(r.sourceStage())).map(DispatchPalletCandidate::orderRefs).collect(Collectors.toSet());
|
||||
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
||||
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||
if(!pallets.isEmpty()) {
|
||||
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
|
||||
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
||||
for(var entry:buckets.entrySet()) {
|
||||
var choices=entry.getValue().stream().sorted(Comparator.comparing(r->wholeSourceKey(selection(r,null)))).toList();
|
||||
var preferred=selection(seed,null).sourceType()!=SourceType.RELABEL && entry.getKey().equals(seed.customerId()+":"+seed.warehousePalletId())?seed:
|
||||
choices.stream().filter(r->r.planPalletId()!=null).findFirst().orElse(null);
|
||||
// PLAN是该客户此实托全部可用库存的一个选择别名,不能把多个别名重复计箱。
|
||||
if(preferred!=null && preferred.planPalletId()!=null)rows.put(wholeSourceKey(selection(preferred,null)),preferred);
|
||||
else for(var row:choices)if(row.returnReceiptId()!=null)rows.put(wholeSourceKey(selection(row,null)),row);
|
||||
}
|
||||
if(facts!=null) {
|
||||
var bound=facts.sources().stream().filter(s->!nativeNos.contains(s.relabelNo()) && s.inventoryPositionId()!=null).collect(Collectors.groupingBy(RelabelDispatchSourcePort.BoundSource::inventoryPositionId,Collectors.summingLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount)));
|
||||
if(facts.positions().stream().anyMatch(p->p.holdPackages()!=bound.getOrDefault(p.positionId(),0L)))reasons.add("实托还有未核齐的实际冻结来源");
|
||||
var ordinary=rows.values().stream().filter(r->selection(r,null).sourceType()!=SourceType.RELABEL && r.warehousePalletId()!=null)
|
||||
.collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId()));
|
||||
var actual=facts.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages)));
|
||||
for(var entry:actual.entrySet())if(entry.getValue()>0 && ordinary.getOrDefault(entry.getKey(),List.of()).stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum()!=entry.getValue())
|
||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
||||
}
|
||||
}
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
|
||||
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
|
||||
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
||||
var sources=new ArrayList<SelectionGroupSource>();
|
||||
for(var entry:rows.entrySet()) {
|
||||
var row=entry.getValue();var selected=selection(row,versions.get(entry.getKey()));String type=selected.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":selected.sourceType().name();
|
||||
String no=selected.sourceType()==SourceType.RELABEL?selected.relabelNo():selected.sourceType()==SourceType.RETURN_RECEIPT?row.returnReceiptNo():row.planNo();
|
||||
sources.add(new SelectionGroupSource(selected,entry.getKey(),row.customerId(),row.customerName(),type,no,row.packageCount()==null?0:row.packageCount()));
|
||||
}
|
||||
var physical=new ArrayList<SelectionGroupPallet>();
|
||||
var physicalRows=rows.values().stream().collect(Collectors.groupingBy(r->r.warehousePalletId()==null?"PLAN:"+r.planPalletId():"PALLET:"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
||||
for(var group:physicalRows.values()) {
|
||||
var first=group.get(0);var destinations=group.stream().map(r->com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode())).distinct().count();
|
||||
if(destinations!=1)reasons.add("同一实托目的地不一致,请核对实际组托");
|
||||
physical.add(new SelectionGroupPallet(first.warehousePalletId(),first.palletNo(),group.stream().map(r->wholeSourceKey(selection(r,null))).sorted().toList(),group.stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum()));
|
||||
}
|
||||
String groupKey="SG-"+wholeHashes.source(Arrays.asList(rows.keySet(),pallets));
|
||||
return new SourceGroup(new SelectionGroup(groupKey,version,List.copyOf(sources),List.copyOf(physical),reasons.isEmpty(),List.copyOf(reasons)),Collections.unmodifiableMap(rows),Collections.unmodifiableMap(versions));
|
||||
}
|
||||
|
||||
/** 整车一次选择的真实客户来源,未持久化客户子排车。 */
|
||||
public record WholeSource(DispatchPalletCandidate candidate,
|
||||
@@ -85,19 +184,39 @@ public class DispatchService {
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public List<WholeSource> lockWholeSources(String warehouseId,
|
||||
List<com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection> selections) {
|
||||
return resolveWholeSources(warehouseId,selections,true,500);
|
||||
return resolveWholeSources(warehouseId,selections,true,500,List.of());
|
||||
}
|
||||
|
||||
/** 草稿可逐托暂存换标选择,正式建单仍要求本申请全部托盘,事实校验和锁序共用。 */
|
||||
/** 加货先并入本车原实托范围,避免持有新托库存后才反向取得旧托占用锁。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public List<WholeSource> lockAdditionalSources(String warehouseId,List<DispatchCommands.CargoSelection> selections,List<DispatchItem> current) {
|
||||
return resolveWholeSources(warehouseId,selections,true,500,current);
|
||||
}
|
||||
|
||||
/** 草稿与正式建单都按完整实托来源组锁定,不能拆开同托申请。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public List<WholeSource> lockDraftSources(String warehouseId,List<com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection> selections) {
|
||||
return resolveWholeSources(warehouseId,selections,false,1000);
|
||||
return resolveWholeSources(warehouseId,selections,true,1000,List.of());
|
||||
}
|
||||
|
||||
/** 先锁全部申请/来源订单,再按物理托盘顺序锁来源根和库存,不能按分组交错。 */
|
||||
private List<WholeSource> resolveWholeSources(String warehouseId,List<com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection> selections,boolean completeRelabel,int limit) {
|
||||
private List<WholeSource> resolveWholeSources(String warehouseId,List<com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection> selections,boolean completeRelabel,int limit,List<DispatchItem> currentItems) {
|
||||
if (selections == null || selections.isEmpty()) return List.of();
|
||||
if (selections.size() > limit) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"选择来源超过完整核验上限");
|
||||
var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(Collectors.toCollection(TreeSet::new));
|
||||
if(selectedKeys.size()!=selections.size())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择");
|
||||
var scopePallets=new TreeSet<Long>();var scopeApplications=new TreeSet<String>();
|
||||
currentItems.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add);
|
||||
if(!scopePallets.isEmpty())relabelSources.snapshot(warehouseId,List.copyOf(scopePallets)).applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).forEach(scopeApplications::add);
|
||||
currentItems.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(scopeApplications::add);
|
||||
for(var selection:selections) {
|
||||
var group=sourceGroup(readSelection(warehouseId,selection),false);
|
||||
if(!group.view().complete())throw new ResponseStatusException(HttpStatus.CONFLICT,"完整实托来源尚未核齐:"+String.join(";",group.view().blockReasons()));
|
||||
if(!selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"须一次完整选择同实托全部来源及整换标申请全部应排托盘");
|
||||
group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add);
|
||||
group.view().sources().stream().map(SelectionGroupSource::selection).filter(s->s.sourceType()==SourceType.RELABEL).map(DispatchCommands.CargoSelection::relabelNo).forEach(scopeApplications::add);
|
||||
}
|
||||
if(!scopePallets.isEmpty())lockRelabelApplications(scopeApplications);
|
||||
var chosen = new java.util.LinkedHashMap<String, WholeSource>();
|
||||
var relabelGroups = selections.stream().filter(s -> s != null && s.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RELABEL)
|
||||
.collect(Collectors.groupingBy(s -> Objects.toString(s.relabelNo(), ""), java.util.TreeMap::new, Collectors.toList()));
|
||||
@@ -108,8 +227,6 @@ public class DispatchService {
|
||||
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus()) || !"TRUCK".equals(application.getDeliveryMethod())
|
||||
|| !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请尚不具备排车资格");
|
||||
relabelBindingGuard.requireBound(application);
|
||||
relabelMeasurements.requireMeasured(application);
|
||||
var complete = java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer, group.getKey())).stream(),
|
||||
returnRelabelStock.candidates(customer,null,group.getKey()).stream()).toList();
|
||||
var requested = group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection::warehousePalletId).collect(Collectors.toSet());
|
||||
@@ -152,8 +269,31 @@ public class DispatchService {
|
||||
if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物缺少真实订单组成");
|
||||
ids.forEach(id -> owners.put(id,row.customerId()));
|
||||
}
|
||||
owners.forEach((id,customer) -> { if (wholeSources.lockOrder(customer,id) == null)
|
||||
var allOrders=new TreeMap<Long,String>();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))allOrders.put(ref.orderId(),ref.customerId());
|
||||
allOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets));
|
||||
allOrders.putAll(owners);
|
||||
allOrders.forEach((id,customer) -> { if ((owners.containsKey(id)?wholeSources.lockOrder(customer,id):wholeSources.lockOrderIdentity(customer,id)) == null)
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"来源订单缺失或已暂扣,不能排车"); });
|
||||
palletSerial.lock(List.copyOf(scopePallets));
|
||||
// 在共同串行点后重读;新申请/订单/实托必须整笔重试,不能临时追加乱序锁。
|
||||
var currentOrders=new TreeMap<Long,String>();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))currentOrders.put(ref.orderId(),ref.customerId());currentOrders.putAll(owners);
|
||||
currentOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets));
|
||||
if(!currentOrders.equals(allOrders))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托真实来源订单组成已变化,请重新读取");
|
||||
for(var source:chosen.values()) {
|
||||
var fresh=readSelection(warehouseId,source.selection());var group=sourceGroup(fresh,!scopePallets.isEmpty());
|
||||
if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()) || group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!scopePallets.contains(p)))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源组成已变化,请重新读取");
|
||||
}
|
||||
for(var no:relabelGroups.keySet()) {
|
||||
String customer=wholeSources.relabelOwner(no);var application=mutations().lockRelabel(customer,no);
|
||||
relabelBindingGuard.requireBound(application);relabelMeasurements.requireMeasured(application);
|
||||
}
|
||||
var availableBuckets=new HashMap<String,Long>();
|
||||
for(var source:chosen.values())if(source.selection().sourceType()==SourceType.INBOUND_PLAN && source.candidate().warehousePalletId()!=null) {
|
||||
String bucket=source.candidate().customerId()+":"+source.candidate().warehousePalletId();
|
||||
if(availableBuckets.put(bucket,source.candidate().planPalletId())!=null || chosen.values().stream().anyMatch(s->s.selection().sourceType()==SourceType.RETURN_RECEIPT && bucket.equals(s.candidate().customerId()+":"+s.candidate().warehousePalletId())))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"同一可用库存组成不能按多个来源别名重复计入");
|
||||
}
|
||||
var result = new java.util.ArrayList<WholeSource>();
|
||||
for (var source : chosen.values().stream().sorted(java.util.Comparator.<WholeSource>comparingLong(s ->
|
||||
s.candidate().warehousePalletId() == null ? Long.MAX_VALUE : s.candidate().warehousePalletId())
|
||||
@@ -163,7 +303,11 @@ public class DispatchService {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"计划托盘来源已变化");
|
||||
if (row.returnReceiptId() != null && wholeSources.lockReturn(row.customerId(),row.returnReceiptId()) == null)
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"退回实收来源已变化");
|
||||
String version = wholeSourceVersion(row,source.sourceKey(),true);
|
||||
wholeSources.stock(row.customerId(),row.warehousePalletId(),true);
|
||||
}
|
||||
for(var source:chosen.values()) {
|
||||
var row=readSelection(warehouseId,source.selection());var group=sourceGroup(row,!scopePallets.isEmpty());String version=group.versions().get(source.sourceKey());
|
||||
if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"锁内完整实托组成未核齐");
|
||||
if (!Objects.equals(version,source.selection().sourceVersion()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源数量、组成或版本已变化,请重新核对");
|
||||
result.add(new WholeSource(row,source.selection(),source.sourceKey(),version));
|
||||
@@ -171,8 +315,89 @@ public class DispatchService {
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 外层草稿整批已锁全集时只重入子集,不让嵌套正式创建替换或追加申请锁。 */
|
||||
private void lockRelabelApplications(Set<String> requested) {
|
||||
Object existing=org.springframework.transaction.support.TransactionSynchronizationManager.getResource(relabelLockScope);
|
||||
if(existing instanceof Set<?> prior) {
|
||||
if(!prior.containsAll(requested))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托申请锁范围已变化,请重新读取完整组成");
|
||||
return;
|
||||
}
|
||||
relabelSources.lockApplications(List.copyOf(requested));var locked=Set.copyOf(requested);
|
||||
org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked);
|
||||
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization() {
|
||||
/** 新事务不能借用被挂起事务的申请锁证明。 */
|
||||
@Override public void suspend(){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);}
|
||||
/** 恢复原事务时恢复其同一锁集合。 */
|
||||
@Override public void resume(){org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked);}
|
||||
/** 事务结束后清除本次锁集合。 */
|
||||
@Override public void afterCompletion(int status){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);}
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
// 合法空车没有来源或实托可锁;非空明细即使尚未关联实托,仍必须经过原严格校验。
|
||||
if(items.isEmpty()) {
|
||||
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("空排车仍须明确起运仓库");
|
||||
return new RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),
|
||||
wholeHashes.source(Map.of("warehouseId",warehouse,"items",List.of())),true,List.of());
|
||||
}
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||
var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet<String>();
|
||||
initial.applications().forEach(a->applications.add(a.relabelNo()));
|
||||
items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(applications::add);
|
||||
var codes=items.stream().map(DispatchItem::getPalletNo).filter(Objects::nonNull).distinct().toList();
|
||||
var nativeBefore=wholeSources.nativeApplications(warehouse,codes);nativeBefore.forEach(a->applications.add(a.relabelNo()));
|
||||
lockRelabelApplications(applications);
|
||||
var orders=executionOrders(warehouse,items,pallets);
|
||||
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
|
||||
palletSerial.lock(List.copyOf(pallets));
|
||||
var current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets));
|
||||
var selectedApplications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet());
|
||||
if(wholeSources.nativeApplications(warehouse,codes).stream().anyMatch(a->!selectedApplications.contains(a.relabelNo())))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的原生退货换标来源");
|
||||
if(!orders.equals(executionOrders(warehouse,items,pallets)) || current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!pallets.contains(p)))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"本车实托来源组成已变化,请回平台核对");
|
||||
var positionIds=current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet());
|
||||
var stockScopes=new TreeMap<Long,TreeSet<String>>();
|
||||
for(var position:current.positions())if(position.warehousePalletId()!=null)stockScopes.computeIfAbsent(position.warehousePalletId(),p->new TreeSet<>()).add(position.customerId());
|
||||
for(var entry:stockScopes.entrySet())for(var customer:entry.getValue())wholeSources.stock(customer,entry.getKey(),true);
|
||||
current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets));
|
||||
if(!positionIds.equals(current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet()))
|
||||
|| !orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托实际库存位置或真实订单组成已变化,请回平台核对");
|
||||
if(!items.isEmpty()) {
|
||||
var picked=wholeSources.pickedItems(items.get(0).getDispatchOrderId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||
var expected=new HashMap<String,Long>();
|
||||
for(var item:items)if(!"RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getWarehousePalletId()!=null) {
|
||||
long left=item.getPackageCount()-picked.getOrDefault(item.getId(),0L);
|
||||
if(left<0)throw new ResponseStatusException(HttpStatus.CONFLICT,"普通来源实际拣货超过本来源箱数");
|
||||
expected.merge(item.getCustomerId()+":"+item.getWarehousePalletId(),left,Math::addExact);
|
||||
}
|
||||
var actual=current.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages)));
|
||||
for(var entry:expected.entrySet())if(!Objects.equals(actual.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托普通来源剩余箱数已变化,请回平台核对");
|
||||
for(var entry:actual.entrySet())if(!Objects.equals(expected.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的可用库存来源");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 原订单与换标履约单分别锁定,各身份保持真实所属客户。 */
|
||||
private TreeMap<Long,String> executionOrders(String warehouse,List<DispatchItem> items,Set<Long> pallets) {
|
||||
var orders=new TreeMap<Long,String>();for(var ref:wholeSources.physicalOrders(warehouse,List.copyOf(pallets)))orders.put(ref.orderId(),ref.customerId());
|
||||
for(var item:items) {
|
||||
if(item.getOrderId()!=null)orders.put(item.getOrderId(),item.getCustomerId());
|
||||
if(item.getPlanPalletId()!=null)for(var id:wholeSources.sourceOrders(item.getCustomerId(),item.getWarehousePalletId(),item.getPlanPalletId()))orders.put(id,item.getCustomerId());
|
||||
}
|
||||
return orders;
|
||||
}
|
||||
|
||||
/** GET候选与写入锁内使用同一摘要字段;不包含客户端显示顺序或锁拥有者。 */
|
||||
public String wholeSourceVersion(DispatchPalletCandidate row, String key, boolean lockStock) {
|
||||
if(lockStock)wholeSources.stock(row.customerId(),row.warehousePalletId(),true);
|
||||
return sourceGroup(row,false).versions().get(key);
|
||||
}
|
||||
|
||||
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
|
||||
var facts = new java.util.TreeMap<String,Object>();
|
||||
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
||||
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
||||
@@ -233,36 +458,17 @@ public class DispatchService {
|
||||
for(var item:items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).sorted(java.util.Comparator.comparing(DispatchItem::getId)).toList())returnRelabelStock.release(item);
|
||||
for(var group:groups.values()){var first=group.get(0);mutations().restoreRelabel(first.getCustomerId(),first.getSourceBusinessNo());}
|
||||
}
|
||||
@Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
||||
@Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges;
|
||||
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
|
||||
@Resource private DispatchOrderMapper orders;
|
||||
@Resource private DispatchItemMapper items;
|
||||
@Resource private DispatchBolMapper bolLines;
|
||||
@Resource private DispatchPalletLockMapper locks;
|
||||
@Resource private DispatchDraftMapper drafts;
|
||||
@Resource private DispatchCandidateMapper candidates;
|
||||
@Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates;
|
||||
|
||||
public String resolveCustomerScopeForSources(List<Long> planPalletIds, List<Long> returnReceiptIds) {
|
||||
var scopes = new java.util.ArrayList<String>();
|
||||
if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds));
|
||||
if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds));
|
||||
return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources");
|
||||
}
|
||||
@Resource private DispatchPdaTaskMapper pdaTasks;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
|
||||
@Resource private ReturnRelabelDispatchStock returnRelabelStock;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements;
|
||||
|
||||
private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() {
|
||||
return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(candidates,relabelApplications);
|
||||
}
|
||||
private boolean planIdsForReturns(String stage) { return stage == null || stage.isBlank() || "RECEIVED".equals(stage); }
|
||||
private List<DispatchPalletCandidate> measuredRelabelCandidates(List<DispatchPalletCandidate> rows) {
|
||||
var cache = new java.util.HashMap<String, List<com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement>>();
|
||||
for (var row : rows) {
|
||||
@@ -277,440 +483,12 @@ public class DispatchService {
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
"Selected inbound plans");
|
||||
}
|
||||
|
||||
public String resolveCustomerScopeForPallets(List<Long> planPalletIds) {
|
||||
return requireSingleCustomerScope(
|
||||
candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")),
|
||||
"Selected pallets");
|
||||
}
|
||||
|
||||
public String resolveCustomerScopeForRelabel(String relabelNo) {
|
||||
String normalized = trimToNull(relabelNo);
|
||||
RelabelApplication application = normalized == null ? null : relabelApplications.findAnyByRelabelNo(normalized);
|
||||
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "换标单不存在,请检查换标单号");
|
||||
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有平台卡车派送的换标单可以排车");
|
||||
}
|
||||
return application.getCustomerId();
|
||||
}
|
||||
|
||||
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
|
||||
String fbaWarehouseCode, String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
if (!StringUtils.hasText(customerId)) return List.of();
|
||||
List<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
|
||||
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
}
|
||||
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
|
||||
String fbaWarehouseCode, String sourceStage) {
|
||||
return candidates(customerId, planIds, orderNo, fbaWarehouseCode, sourceStage, null);
|
||||
}
|
||||
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
|
||||
String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
List<DispatchPalletCandidate> relabel = java.util.stream.Stream.concat(
|
||||
measuredRelabelCandidates(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(),
|
||||
returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList();
|
||||
if (relabel != null && !relabel.isEmpty()) {
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
|
||||
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
|
||||
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
|
||||
}
|
||||
}
|
||||
if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(),
|
||||
returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList();
|
||||
return result;
|
||||
}
|
||||
private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); }
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) {
|
||||
return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
|
||||
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
|
||||
.filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if (orderIds.isEmpty()) return;
|
||||
List<LastlegOrder> scoped = tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.in(LastlegOrder::getId, orderIds)
|
||||
.orderByAsc(LastlegOrder::getId)
|
||||
.last("FOR UPDATE"));
|
||||
List<String> detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained()))
|
||||
.map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList();
|
||||
if (!detainedNos.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos));
|
||||
}
|
||||
}
|
||||
|
||||
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
|
||||
if (!StringUtils.hasText(customerId)) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
return page(customerId, query, false);
|
||||
}
|
||||
|
||||
public PageResult<DispatchResponse> platformPage(DispatchRequests.Query query) {
|
||||
return page(null, query, true);
|
||||
}
|
||||
|
||||
private PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query,
|
||||
boolean acrossCustomers) {
|
||||
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<DispatchOrder>()
|
||||
.eq(!acrossCustomers, DispatchOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
|
||||
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
|
||||
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
|
||||
.in(query.stage() != null, DispatchOrder::getStatus,
|
||||
query.stage() == null ? List.of() : query.stage().statuses())
|
||||
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
|
||||
query.fbaAppointmentNo())
|
||||
.orderByDesc(DispatchOrder::getCreatedAt));
|
||||
var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream()
|
||||
.map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.<String, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow>of();
|
||||
var appointmentReferences=appointmentAllocations.references(page.getRecords().stream().map(DispatchOrder::getId).toList());
|
||||
List<DispatchResponse> records = page.getRecords().stream()
|
||||
.map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()),
|
||||
List.of(), workflows.get(order.getDispatchNo()),appointmentReferences.getOrDefault(order.getId(),List.of()))).toList();
|
||||
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public DispatchResponse detail(String customerId, String dispatchNo) {
|
||||
DispatchOrder dispatch = find(customerId, dispatchNo);
|
||||
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
|
||||
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
public DispatchResponse platformDetail(String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
}
|
||||
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
|
||||
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
/** 仅编辑未执行且无有效出库或车次占用的排车。 */
|
||||
@Transactional
|
||||
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
requirePalletEditingAllowed(dispatch);
|
||||
String customerId = dispatch.getCustomerId();
|
||||
List<DispatchItem> assignedItems = mutations().items(dispatch);
|
||||
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
|
||||
dispatch.setDriverName(trimToNull(request.driverName()));
|
||||
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
|
||||
dispatch.setCarrierName(trimToNull(request.carrierName()));
|
||||
dispatch.setDepartAt(request.departAt());
|
||||
dispatch.setRoute(itemRoute(assignedItems));
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
|
||||
dispatch.setUpdatedAt(LocalDateTime.now());
|
||||
mutations().saveOrder(dispatch);
|
||||
return response(dispatch, assignedItems);
|
||||
}
|
||||
|
||||
/** 移除普通来源托盘,整单换标只允许整单取消重排。 */
|
||||
@Transactional
|
||||
public DispatchResponse removeItem(String dispatchNo, Long itemId) {
|
||||
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
requirePalletEditingAllowed(dispatch);
|
||||
DispatchItem item = mutations().lockItem(dispatch, itemId);
|
||||
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
|
||||
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车");
|
||||
|
||||
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
|
||||
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())
|
||||
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
|
||||
.toList();
|
||||
return removeLockedItem(dispatch, item, matchingLocks);
|
||||
}
|
||||
|
||||
/** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */
|
||||
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
|
||||
List<DispatchPalletLock> matchingLocks) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
matchingLocks.forEach(lock -> releaseLock(lock, now));
|
||||
mutations().removeItem(item.getId());
|
||||
appointmentAllocations.removeItem(dispatch.getId(),item.getId());
|
||||
|
||||
List<DispatchItem> remaining = mutations().items(dispatch);
|
||||
dispatch.setItemCount(remaining.size());
|
||||
dispatch.setPalletCount(remaining.size());
|
||||
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum());
|
||||
dispatch.setRoute(itemRoute(remaining));
|
||||
dispatch.setUpdatedAt(now);
|
||||
mutations().saveOrder(dispatch);
|
||||
return response(dispatch, remaining);
|
||||
}
|
||||
|
||||
/** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */
|
||||
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|
||||
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|
||||
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
|
||||
}
|
||||
var outbound = mutations().outbound(dispatch, false);
|
||||
if (outbound != null && !CANCELLED.equals(outbound.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
|
||||
}
|
||||
}
|
||||
|
||||
/** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */
|
||||
@Transactional
|
||||
public void cancel(String dispatchNo) {
|
||||
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
if (CANCELLED.equals(dispatch.getStatus())) return;
|
||||
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
|
||||
}
|
||||
String customerId = dispatch.getCustomerId();
|
||||
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|
||||
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱");
|
||||
var outbound = mutations().outbound(dispatch, true);
|
||||
if (outbound != null && !CANCELLED.equals(outbound.getStatus()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
dispatch.setStatus(CANCELLED);
|
||||
workChanges.requirementsChanged();
|
||||
dispatch.setUpdatedAt(now);
|
||||
mutations().saveOrder(dispatch);
|
||||
for (DispatchItem item : mutations().items(dispatch)) {
|
||||
// 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。
|
||||
if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item);
|
||||
}
|
||||
mutations().items(dispatch).stream()
|
||||
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
|
||||
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
|
||||
.forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo));
|
||||
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
|
||||
for (DispatchPalletLock lock : activeLocks) {
|
||||
lock.setStatus("RELEASED");
|
||||
lock.setActiveKey(null);
|
||||
lock.setReleasedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
mutations().saveLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
private DispatchOrder find(String customerId, String dispatchNo) {
|
||||
if (!StringUtils.hasText(customerId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
}
|
||||
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
|
||||
return response(order, itemList, List.of());
|
||||
}
|
||||
|
||||
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
|
||||
List<DispatchResponse.BolLine> bolLineList) {
|
||||
return response(order, itemList, bolLineList, null);
|
||||
}
|
||||
|
||||
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
|
||||
List<DispatchResponse.BolLine> bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
|
||||
return response(order,itemList,bolLineList,workflow,appointmentAllocations.references(List.of(order.getId())).getOrDefault(order.getId(),List.of()));
|
||||
}
|
||||
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
|
||||
List<DispatchResponse.BolLine> bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) {
|
||||
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
|
||||
order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList),
|
||||
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
|
||||
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
|
||||
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
|
||||
Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||
Objects.requireNonNullElse(order.getPickedPackageCount(), 0),
|
||||
Objects.requireNonNullElse(order.getShippedPackageCount(), 0),
|
||||
Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
|
||||
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(),
|
||||
order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(),
|
||||
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
|
||||
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
|
||||
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
|
||||
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
|
||||
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow));
|
||||
}
|
||||
|
||||
/** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */
|
||||
private DispatchResponse.Actions actions(DispatchOrder order, List<DispatchItem> itemList,
|
||||
com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
|
||||
if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo());
|
||||
var outbound = mutations().outbound(order, false);
|
||||
boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus());
|
||||
String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作"
|
||||
: Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0
|
||||
|| Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null;
|
||||
String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason;
|
||||
String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null;
|
||||
String removeReason = editReason != null ? editReason : itemList.stream()
|
||||
.anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null;
|
||||
String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus())
|
||||
? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单")
|
||||
: !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), ""))
|
||||
? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null)
|
||||
? "请先完成收货并核对实物托盘" : null;
|
||||
return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason,
|
||||
removeReason == null, removeReason, transferReason == null, transferReason,
|
||||
outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus());
|
||||
}
|
||||
|
||||
/** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */
|
||||
private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() {
|
||||
return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(
|
||||
orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks);
|
||||
}
|
||||
|
||||
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
|
||||
return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static String itemRoute(List<DispatchItem> assignedItems) {
|
||||
return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode())
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" "));
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static boolean hasValidDestination(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode())
|
||||
&& StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone());
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
|| StringUtils.hasText(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static String itemFbaCode(DispatchItem item) {
|
||||
return StringUtils.hasText(item.getFbaWarehouseCode())
|
||||
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) {
|
||||
List<DispatchPalletCandidate> found = candidates.lockSelected(customerId, List.of(planPalletId));
|
||||
if (found.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch");
|
||||
}
|
||||
return found.get(0);
|
||||
}
|
||||
private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); }
|
||||
private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); }
|
||||
private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) {
|
||||
if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源");
|
||||
if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId());
|
||||
var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId()));
|
||||
if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排");
|
||||
return found.get(0);
|
||||
}
|
||||
|
||||
private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) {
|
||||
DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey());
|
||||
if (active == null && candidate.warehousePalletId() != null && candidate.planPalletId() != null) {
|
||||
// Read legacy plan-level locks created before physical-pallet locking was introduced.
|
||||
active = locks.lockActive(customerId, "PLAN:" + candidate.planPalletId());
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) {
|
||||
if (lock == null || !draftToken.equals(lock.getDraftToken())
|
||||
|| !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft");
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseLock(DispatchPalletLock lock, LocalDateTime now) {
|
||||
lock.setStatus("RELEASED");
|
||||
lock.setActiveKey(null);
|
||||
lock.setReleasedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
locks.updateById(lock);
|
||||
}
|
||||
|
||||
private static List<String> splitOrderRefs(String refs, String fallback) {
|
||||
String value = StringUtils.hasText(refs) ? refs : fallback;
|
||||
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
|
||||
}
|
||||
|
||||
private static String defaultStatus(String status) {
|
||||
return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING";
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
private static String mergeCartonMarks(String regularMarks, String relabelMarks) {
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) {
|
||||
Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add);
|
||||
}
|
||||
return values.isEmpty() ? null : String.join(",", values);
|
||||
}
|
||||
|
||||
private static void validateSourceStage(String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Long> normalizeIds(List<Long> ids, String label) {
|
||||
if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty");
|
||||
}
|
||||
List<Long> normalized = ids.stream().distinct().sorted().toList();
|
||||
if (normalized.size() > 500) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String requireSingleCustomerScope(List<String> customerIds, String source) {
|
||||
if (customerIds == null || customerIds.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found");
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ public interface FleetAppointmentImpactMapper {
|
||||
@Select("""
|
||||
<script>SELECT DISTINCT t.trip_no,t.status,t.planned_departure_at FROM ll_fleet_trip_dispatch d
|
||||
JOIN ll_fleet_trip t ON t.id=d.trip_id WHERE t.status NOT IN ('CANCELLED','COMPLETED') AND
|
||||
<foreach collection='rows' item='r' open='(' separator=' OR ' close=')'>(d.customer_id=#{r.customerId} AND d.dispatch_no=#{r.dispatchNo})</foreach>
|
||||
<foreach collection='rows' item='r' open='(' separator=' OR ' close=')'>(d.dispatch_no=#{r.dispatchNo} AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.dispatch_id AND i.customer_id=#{r.customerId}))</foreach>
|
||||
ORDER BY t.trip_no</script>
|
||||
""")
|
||||
List<Trip> trips(@Param("rows") List<Dispatch> rows);
|
||||
|
||||
+7
-1
@@ -14,7 +14,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */
|
||||
/** 由PDA绑定仓读取本车交接状态;实际派送归属逐行核验。 */
|
||||
@Service
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public class FleetHandoverPdaQuery {
|
||||
@@ -45,6 +45,12 @@ public class FleetHandoverPdaQuery {
|
||||
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();
|
||||
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();
|
||||
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, "缺少唯一原始发车记录");
|
||||
|
||||
+10
-1
@@ -90,8 +90,17 @@ public class FleetBookingReservations {
|
||||
}
|
||||
}
|
||||
private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");}
|
||||
/** 父车次锁下读取有效外接占位支持的卸货目的地,不用展示响应推导。 */
|
||||
public Set<String> requiredRouteDestinations(long tripId) {
|
||||
var keys=new LinkedHashSet<String>();
|
||||
for(var row:bookings.list(tripId)) if(unbound(row)||"BOUND".equals(row.getStatus())) {
|
||||
if(row.getDestinationKey()==null || row.getDestinationKey().isBlank())throw conflict("已确认外接预订缺少真实卸货目的地");
|
||||
keys.add(row.getDestinationKey());
|
||||
}
|
||||
return Collections.unmodifiableSet(keys);
|
||||
}
|
||||
public void requireRoute(FleetTripEntity trip,List<FleetTripModels.Stop> stops) {
|
||||
for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1)
|
||||
for(var key:requiredRouteDestinations(trip.getId())) if(index(stops,key)<1)
|
||||
throw conflict("路线仍须包含已确认外接预订的卸货站");
|
||||
}
|
||||
private void add(List<TripCapacityPolicy.Booking> loads,List<FleetTripModels.Stop> stops,FleetBookingEntity row){
|
||||
|
||||
+7
-79
@@ -34,6 +34,11 @@ public class FleetTripExecutionService {
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingReservations bookingReservations;
|
||||
|
||||
/** 排车域先核对冻结司机及附件引用;本域继续校验该司机当前或历史证照归属。 */
|
||||
public DriverModels.Download dispatchDriverLicense(long driverId,long fileId) {
|
||||
return drivers.download(driverId,fileId);
|
||||
}
|
||||
|
||||
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
|
||||
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
|
||||
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
|
||||
@@ -203,69 +208,6 @@ public class FleetTripExecutionService {
|
||||
|
||||
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (binding == null) return missingGate();
|
||||
return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public Map<String, FleetExecutionModels.Gate> gates(String warehouseId, List<String> numbers) {
|
||||
if (numbers.isEmpty()) return Map.of();
|
||||
var result = new HashMap<String, FleetExecutionModels.Gate>();
|
||||
numbers.forEach(number -> result.put(number, missingGate()));
|
||||
var byTrip = new HashMap<String, FleetExecutionModels.Gate>();
|
||||
for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate(
|
||||
byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding));
|
||||
return result;
|
||||
}
|
||||
|
||||
private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
requireWarehouse(trip, warehouseId);
|
||||
var manifest = manifests(trip, false);
|
||||
String reason = blockReason(trip, manifest);
|
||||
String warning = null;
|
||||
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
|
||||
if (reason == null) reason = loadingReviewBlockReason(trip);
|
||||
if (reason == null) for (var member : bindings.listByTrip(trip.getId())) {
|
||||
reason = appointmentAllocations.departureBlockReason(member.getDispatchNo());
|
||||
if (reason != null) break;
|
||||
}
|
||||
if (reason == null) {
|
||||
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
|
||||
else reason = driverBlockReason(trip, driver);
|
||||
if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险";
|
||||
}
|
||||
boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null;
|
||||
return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning,
|
||||
canPick, canPick ? null : "当前车次不允许继续拣货");
|
||||
}
|
||||
|
||||
private static FleetExecutionModels.Gate missingGate() {
|
||||
return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核");
|
||||
}
|
||||
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
|
||||
if (!gate.canPick()) return gate;
|
||||
var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo());
|
||||
if(fact==null)return gate.withPicking(false,"整车审核事实缺失");
|
||||
var review = fact.dispatchReview();
|
||||
String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status())
|
||||
? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货";
|
||||
return gate.withPicking(reason == null, reason);
|
||||
}
|
||||
private String loadingReviewBlockReason(FleetTripEntity trip) {
|
||||
var members=bindings.listByTrip(trip.getId());
|
||||
var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList());
|
||||
return !members.isEmpty() && members.stream().allMatch(b->facts.containsKey(b.getDispatchNo()) && "APPROVED".equals(facts.get(b.getDispatchNo()).loadingReview().status()))?null:"请先通过本车理货审核再发车";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
|
||||
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
|
||||
}
|
||||
@@ -275,7 +217,8 @@ public class FleetTripExecutionService {
|
||||
}
|
||||
private String blockReason(FleetTripEntity trip, List<FleetExecutionModels.Manifest> manifests) {
|
||||
if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划";
|
||||
if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机";
|
||||
String vehicleReason=currentVehicleBlockReason(trip,false);if(vehicleReason!=null)return vehicleReason;
|
||||
if(trip.getDriverName()==null || trip.getDriverName().isBlank())return "请先确认本趟司机资料";
|
||||
if (manifests.isEmpty()) return "车次尚未关联排车单";
|
||||
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
|
||||
for (var binding : bindings.listByTrip(trip.getId())) {
|
||||
@@ -290,13 +233,6 @@ public class FleetTripExecutionService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private String loadBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
var load = load(trip);
|
||||
if (load == null) return "请在平台确认本车实际装载";
|
||||
if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认";
|
||||
try { capacity(trip, cargo, load.pallets()); return null; }
|
||||
catch (ResponseStatusException error) { return error.getReason(); }
|
||||
}
|
||||
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
|
||||
List<FleetExecutionModels.Measurement> pallets) {
|
||||
List<FleetTripModels.Stop> stops;
|
||||
@@ -333,19 +269,11 @@ public class FleetTripExecutionService {
|
||||
var claim = resources.find(kind, id);
|
||||
if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化");
|
||||
}
|
||||
private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) {
|
||||
if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案";
|
||||
if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照";
|
||||
return null;
|
||||
}
|
||||
private FleetTripEntity requireTrip(String tripNo, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
return trip;
|
||||
}
|
||||
private static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
|
||||
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
|
||||
}
|
||||
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
|
||||
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
|
||||
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
|
||||
|
||||
@@ -349,13 +349,28 @@ public class FleetTripService {
|
||||
var trip = repository.lockTrip(binding.getTripNo());
|
||||
if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物");
|
||||
List<FleetTripModels.Stop> existing = read(trip.getStopsJson(),new TypeReference<List<FleetTripModels.Stop>>() { });
|
||||
var stops = new ArrayList<>(existing);
|
||||
if (stops.isEmpty()) stops.add(new FleetTripModels.Stop(trip.getWarehouseName(),null,"ORIGIN:"+trip.getWarehouseId()));
|
||||
var keys = new HashSet<String>(); stops.forEach(stop -> keys.add(stop.destinationKey()));
|
||||
for (var stop : destinations) if (keys.add(stop.destinationKey())) stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey()));
|
||||
String originKey="ORIGIN:"+trip.getWarehouseId();
|
||||
var origin=existing.isEmpty()?new FleetTripModels.Stop(trip.getWarehouseName(),null,originKey):existing.get(0);
|
||||
if(!originKey.equals(origin.destinationKey()))throw conflict("本车缺少与起运仓一致的路线起点");
|
||||
var requested=new LinkedHashMap<String,FleetExecutionModels.DispatchStop>();
|
||||
for(var stop:destinations) {
|
||||
if(stop==null || stop.destinationKey()==null || stop.destinationKey().isBlank() || originKey.equals(stop.destinationKey()))throw conflict("实际货物缺少有效卸货目的地");
|
||||
requested.putIfAbsent(stop.destinationKey(),stop);
|
||||
}
|
||||
var supported=new HashSet<>(bookingReservations.requiredRouteDestinations(trip.getId()));
|
||||
supported.addAll(requested.keySet());
|
||||
var stops=new ArrayList<FleetTripModels.Stop>();stops.add(origin);
|
||||
var keys=new HashSet<String>();keys.add(originKey);
|
||||
// 仅删除失去货物和有效预订支持的站;保留其余站的原相对顺序与事实快照。
|
||||
for(var stop:existing.subList(Math.min(1,existing.size()),existing.size()))
|
||||
if(supported.contains(stop.destinationKey()) && keys.add(stop.destinationKey()))stops.add(stop);
|
||||
for(var stop:requested.values()) if(keys.add(stop.destinationKey()))stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey()));
|
||||
// 不能为缺失的外接站编造名称/地址;异常承诺必须阻断整笔主单事务。
|
||||
bookingReservations.requireRoute(trip,stops);
|
||||
long version=trip.getRecordVersion();
|
||||
trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null);
|
||||
trip.setRecordVersion(trip.getRecordVersion()+1); trip.setUpdatedAt(now());
|
||||
if (repository.updateTrip(trip,new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId())) != 1
|
||||
trip.setRecordVersion(Math.addExact(version,1)); trip.setUpdatedAt(now());
|
||||
if (repository.updateTrip(trip,new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)) != 1
|
||||
|| repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败");
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public interface VehicleFulfillmentMapper {
|
||||
+ "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)";
|
||||
String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")";
|
||||
String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b "
|
||||
+ "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) "
|
||||
+ "ON b.dispatch_id=d.dispatch_order_id AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=b.dispatch_id AND CAST(i.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128))) "
|
||||
+ "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")";
|
||||
String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))";
|
||||
String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))";
|
||||
|
||||
-1
@@ -14,7 +14,6 @@ public interface InboundPlanQueryMapper {
|
||||
String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN 'MULTIPLE' ELSE a.appointment_status END";
|
||||
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
|
||||
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
|
||||
" AND dispatch.customer_id=di.customer_id" +
|
||||
" WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" +
|
||||
" AND dispatch.status<>'CANCELLED')";
|
||||
|
||||
|
||||
+2
-2
@@ -65,11 +65,11 @@ public interface InventoryCommandMapper {
|
||||
"AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " +
|
||||
"AND l.outbound_order_no=o.dispatch_no AND EXISTS (" +
|
||||
"SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " +
|
||||
"ON p.customer_id=t.customer_id AND p.task_id=t.id " +
|
||||
"ON p.task_id=t.id " +
|
||||
"JOIN ll_inventory_operation op ON op.customer_id=p.customer_id " +
|
||||
"AND op.operation_type='SHIP' AND op.status='COMPLETED' " +
|
||||
"AND op.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':',p.dispatch_item_id,':',p.idempotency_key,':SHIP') " +
|
||||
"WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " +
|
||||
"WHERE p.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " +
|
||||
"AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " +
|
||||
"AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " +
|
||||
"OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no)) " +
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inventory.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 共同串行行只按全局真实实托主键建行和取锁,不承载业务占用状态。 */
|
||||
@Mapper
|
||||
public interface PhysicalPalletSerialMapper {
|
||||
/** 首次使用建立空实托的串行行,重复使用保留首次创建时间。 */
|
||||
@Insert("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id) VALUES(#{warehousePalletId}) "
|
||||
+ "ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id)")
|
||||
int ensure(@Param("warehousePalletId") long warehousePalletId);
|
||||
|
||||
/** 每次都取得实际主键行锁,不以一级或二级缓存替代数据库锁。 */
|
||||
@Select("SELECT warehouse_pallet_id FROM ll_physical_pallet_guard "
|
||||
+ "WHERE warehouse_pallet_id=#{warehousePalletId} FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
Long lock(@Param("warehousePalletId") long warehousePalletId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inventory.port;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 全局真实实托组成变更的共同事务串行点,不承载业务占用或客户归属。 */
|
||||
public interface PhysicalPalletSerialPort {
|
||||
/** 加入调用方原事务,对真实实托去重升序建行并逐主键锁定;空目标不造身份。 */
|
||||
void lock(List<Long> warehousePalletIds);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.inventory.repository;
|
||||
|
||||
import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 真实实托共同串行行的持久化入口,所有 SQL 由 Mapper 执行。 */
|
||||
@Repository
|
||||
public class PhysicalPalletSerialRepository {
|
||||
private final PhysicalPalletSerialMapper mapper;
|
||||
|
||||
/** 注入真实实托串行映射器。 */
|
||||
public PhysicalPalletSerialRepository(PhysicalPalletSerialMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** 调用方已完成去重升序;先按该顺序建齐行,再按同序逐主键锁定。 */
|
||||
public void lockInOrder(List<Long> warehousePalletIds) {
|
||||
// upsert 本身也会持行锁,因此建行和后续 FOR UPDATE 必须使用同一稳定顺序。
|
||||
for (Long id : warehousePalletIds) mapper.ensure(id);
|
||||
for (Long id : warehousePalletIds) {
|
||||
if (!id.equals(mapper.lock(id))) throw new IllegalStateException("实托共同串行行不存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.inventory.service;
|
||||
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** 调用方原事务中的实托共同串行点,不生成实托身份或另建业务占用。 */
|
||||
@Service
|
||||
public class PhysicalPalletSerialService implements PhysicalPalletSerialPort {
|
||||
private final PhysicalPalletSerialRepository repository;
|
||||
|
||||
/** 注入实托串行行的唯一持久化入口。 */
|
||||
public PhysicalPalletSerialService(PhysicalPalletSerialRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** 校验完整目标集合后去重升序锁齐;没有真实实托时不插入任何替代行。 */
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void lock(List<Long> warehousePalletIds) {
|
||||
// 直接调用同样不能绕过事务要求,锁一直持有到外层业务提交或回滚。
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()
|
||||
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly())
|
||||
throw new IllegalStateException("实托共同串行锁必须加入调用方现存写事务");
|
||||
if (warehousePalletIds == null || warehousePalletIds.stream().anyMatch(id -> id == null || id <= 0))
|
||||
throw new IllegalArgumentException("实托集合只接受合法真实实托ID");
|
||||
if (warehousePalletIds.isEmpty()) return;
|
||||
repository.lockInOrder(List.copyOf(new TreeSet<>(warehousePalletIds)));
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
|
||||
|
||||
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
|
||||
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id " +
|
||||
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
|
||||
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
|
||||
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
|
||||
+94
@@ -7,11 +7,105 @@ import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
|
||||
String MUTATION_IDS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
|
||||
String MUTATION_CODES = "<foreach collection='palletCodes' item='code' open='(' separator=',' close=')'>#{code}</foreach>";
|
||||
String MUTATION_APPS = "<choose><when test='applicationNos != null and applicationNos.size() > 0'>"
|
||||
+ "<foreach collection='applicationNos' item='no' open='(' separator=',' close=')'>#{no}</foreach>"
|
||||
+ "</when><otherwise>(NULL)</otherwise></choose>";
|
||||
|
||||
/** 真实订单身份不携带暂扣资格,仓内作业只与运输取得相同的身份锁。 */
|
||||
record MutationOrder(String customerId, Long orderId) { }
|
||||
/** 原生退货只有托盘码时保持ID为空,调用方必须通过权威资源提供者解析。 */
|
||||
record MutationPallet(Long palletId, String palletCode) { }
|
||||
/** 组成重读比较逐位置、箱记录与换标明细,不以客户或首行代表整托。 */
|
||||
record MutationFact(String kind, Long id, String customerId, Long applicationId, Long referenceId,
|
||||
Long palletId, String palletCode, Long quantity, Long version) { }
|
||||
|
||||
/** 从全客户库存与原生退货扩展相关申请,不按排车资格过滤。 */
|
||||
@Select("<script>SELECT a.* FROM ll_relabel_application a WHERE a.overseas_warehouse_id=#{warehouseId} AND (a.relabel_no IN " + MUTATION_APPS
|
||||
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_inventory_position p ON p.id=i.source_inventory_position_id AND p.customer_id=i.customer_id "
|
||||
+ "WHERE i.application_id=a.id AND i.customer_id=a.customer_id AND p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS + ")"
|
||||
+ " OR EXISTS(SELECT 1 FROM ll_return_inventory_carton c WHERE c.relabel_application_id=a.id AND c.customer_id=a.customer_id "
|
||||
+ "AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND c.pallet_code IN " + MUTATION_CODES + ")"
|
||||
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_return_inventory_carton c ON c.id=i.return_carton_id AND c.customer_id=i.customer_id "
|
||||
+ "WHERE i.application_id=a.id AND i.customer_id=a.customer_id AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND c.pallet_code IN " + MUTATION_CODES + ")) ORDER BY a.relabel_no</script>")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<RelabelApplication> mutationApplications(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
|
||||
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
|
||||
|
||||
/** 原申请全部绑定实托参与共同串行集合;无托退货不伪造资源ID。 */
|
||||
@Select("<script>SELECT DISTINCT p.pallet_id AS palletId,p.pallet_no AS palletCode FROM ll_relabel_item i "
|
||||
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||
+ "JOIN ll_inventory_position p ON p.id=i.source_inventory_position_id AND p.customer_id=i.customer_id "
|
||||
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id>0 "
|
||||
+ "UNION SELECT DISTINCT NULL AS palletId,c.pallet_code AS palletCode FROM ll_return_inventory_carton c "
|
||||
+ "LEFT JOIN ll_relabel_item i ON i.return_carton_id=c.id AND i.customer_id=c.customer_id "
|
||||
+ "JOIN ll_relabel_application a ON (a.id=i.application_id OR a.id=c.relabel_application_id) AND a.customer_id=c.customer_id "
|
||||
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 "
|
||||
+ "AND c.pallet_code IS NOT NULL AND c.pallet_code<>'' AND UPPER(c.pallet_code)<>'UNBOUND'</script>")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<MutationPallet> mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List<String> applicationNos);
|
||||
|
||||
/** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */
|
||||
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId FROM ll_relabel_application a WHERE a.relabel_no IN " + MUTATION_APPS
|
||||
+ " UNION SELECT i.customer_id,i.source_inbound_order_id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND i.return_carton_id IS NULL"
|
||||
+ " UNION SELECT p.customer_id,b.inbound_order_id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
|
||||
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
|
||||
+ " UNION SELECT r.customer_id,r.order_id FROM ll_inbound_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id "
|
||||
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND r.warehouse_pallet_id IN " + MUTATION_IDS
|
||||
+ " UNION SELECT r.customer_id,r.order_id FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id "
|
||||
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
|
||||
+ " UNION SELECT l.customer_id,l.order_id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "JOIN ll_inbound_plan_pallet_allocation x ON x.pallet_id=p.id AND x.plan_id=p.plan_id AND x.customer_id=p.customer_id "
|
||||
+ "JOIN ll_inbound_plan_line l ON l.id=x.line_id AND l.plan_id=x.plan_id AND l.customer_id=x.customer_id "
|
||||
+ "WHERE ip.overseas_warehouse_id=#{warehouseId} AND p.warehouse_pallet_id IN " + MUTATION_IDS
|
||||
+ " UNION SELECT c.customer_id,o.lastleg_order_id FROM ll_return_inventory_carton c LEFT JOIN ll_return_order o ON o.customer_id=c.customer_id "
|
||||
+ "AND LOCATE(CONCAT(',',o.return_no,','),CONCAT(',',c.source_return_no,','))>0 WHERE c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 "
|
||||
+ "AND (c.pallet_code IN " + MUTATION_CODES + " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||
+ "WHERE i.return_carton_id=c.id AND i.customer_id=c.customer_id AND a.relabel_no IN " + MUTATION_APPS + "))</script>")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<MutationOrder> mutationOrders(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
|
||||
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
|
||||
|
||||
/** 逐申请业务号取锁,完整顺序由调用方在首次锁前确定。 */
|
||||
@Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{number} FOR UPDATE")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<Long> lockMutationApplication(@Param("number") String number);
|
||||
|
||||
/** 只锁真实订单身份,不为仓内作业附加暂扣或运输状态资格。 */
|
||||
@Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} FOR UPDATE")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long lockMutationOrder(@Param("customerId") String customerId, @Param("orderId") long orderId);
|
||||
|
||||
/** 重读全部位置和逐申请绑定,发现同锁集合内的新成员也必须重试。 */
|
||||
@Select("<script>SELECT 'POSITION' AS kind,p.id,p.customer_id AS customerId,NULL AS applicationId,p.balance_id AS referenceId,p.pallet_id AS palletId,p.pallet_no AS palletCode,"
|
||||
+ "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) AS quantity,p.version FROM ll_inventory_position p "
|
||||
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
|
||||
+ " UNION ALL SELECT 'ITEM',i.id,i.customer_id,i.application_id,COALESCE(i.source_inventory_position_id,i.return_carton_id),NULL,NULL,i.notified_package_count,NULL "
|
||||
+ "FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id WHERE a.relabel_no IN " + MUTATION_APPS
|
||||
+ " UNION ALL SELECT 'RETURN',c.id,c.customer_id,c.relabel_application_id,c.source_receipt_id,NULL,c.pallet_code,c.total_quantity,NULL FROM ll_return_inventory_carton c "
|
||||
+ "WHERE c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND (c.pallet_code IN " + MUTATION_CODES
|
||||
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||
+ "WHERE i.return_carton_id=c.id AND i.customer_id=c.customer_id AND a.relabel_no IN " + MUTATION_APPS + "))</script>")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<MutationFact> mutationFacts(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
|
||||
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
|
||||
|
||||
/** 共同实托锁持有期间检查正式和未过期草稿,过期判断使用调用方统一UTC业务时钟。 */
|
||||
@Select("<script>SELECT (SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND active_warehouse_pallet_id IN " + MUTATION_IDS
|
||||
+ " AND (dispatch_order_id IS NOT NULL OR expires_at IS NULL OR expires_at>#{at})) + "
|
||||
+ "(SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id IN " + MUTATION_IDS
|
||||
+ " AND (dispatch_order_id IS NOT NULL OR expires_at IS NULL OR expires_at>#{at}))</script>")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
long mutationTransportClaims(@Param("palletIds") List<Long> palletIds, @Param("at") java.time.LocalDateTime at);
|
||||
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
|
||||
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
|
||||
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.palletops.repository;
|
||||
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationFact;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationOrder;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationPallet;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 仓内实托组成及身份锁的数据访问,不承担换标或运输资格判断。 */
|
||||
@Repository
|
||||
public class PdaPalletOperationRepository {
|
||||
private final PdaPalletOperationMapper mapper;
|
||||
|
||||
/** 仅复用本域Mapper,不依赖排车内部服务或跨域Mapper。 */
|
||||
public PdaPalletOperationRepository(PdaPalletOperationMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 未解析的原生退货托盘码由Service交给真实资源提供者解析,不伪造ID。 */
|
||||
public record Composition(List<RelabelApplication> applications, List<MutationOrder> orders,
|
||||
List<MutationPallet> pallets, List<MutationFact> facts) { }
|
||||
|
||||
/** 从全部客户的源目标实托扩展申请及其其他实托,保留完整原始组成。 */
|
||||
public Composition composition(String warehouseId, Map<Long,String> seedPallets, RelabelApplication source) {
|
||||
var ids = new TreeSet<>(seedPallets.keySet());
|
||||
var codes = new TreeSet<>(seedPallets.values());
|
||||
var numbers = new TreeSet<String>();
|
||||
if (source != null) numbers.add(source.getRelabelNo());
|
||||
List<RelabelApplication> applications;
|
||||
List<MutationPallet> related;
|
||||
boolean changed;
|
||||
do {
|
||||
applications = mapper.mutationApplications(warehouseId, List.copyOf(ids), List.copyOf(codes), List.copyOf(numbers));
|
||||
changed = applications.stream().map(RelabelApplication::getRelabelNo).map(numbers::add).reduce(false, Boolean::logicalOr);
|
||||
related = mapper.mutationPallets(warehouseId, List.copyOf(numbers));
|
||||
for (var pallet : related) {
|
||||
if (pallet.palletId() != null && pallet.palletId() > 0) changed |= ids.add(pallet.palletId());
|
||||
if (pallet.palletCode() != null && !pallet.palletCode().isBlank()) changed |= codes.add(pallet.palletCode());
|
||||
}
|
||||
} while (changed);
|
||||
var pallets = new ArrayList<MutationPallet>();
|
||||
seedPallets.forEach((id,code) -> pallets.add(new MutationPallet(id,code)));
|
||||
pallets.addAll(related);
|
||||
return new Composition(List.copyOf(applications),
|
||||
List.copyOf(mapper.mutationOrders(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))),
|
||||
List.copyOf(pallets), List.copyOf(mapper.mutationFacts(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))));
|
||||
}
|
||||
|
||||
/** 申请按调用方已收齐的业务号顺序逐行加锁。 */
|
||||
public boolean lockApplication(String number) { return mapper.lockMutationApplication(number).size() == 1; }
|
||||
|
||||
/** 订单只锁身份,不添加暂扣或履约状态筛选。 */
|
||||
public boolean lockOrder(MutationOrder order) { return mapper.lockMutationOrder(order.customerId(),order.orderId()) != null; }
|
||||
|
||||
/** 已持有共同串行点后读取正式及当前业务时钟下尚未过期的草稿占用。 */
|
||||
public long transportClaims(List<Long> pallets, LocalDateTime at) { return mapper.mutationTransportClaims(pallets,at); }
|
||||
}
|
||||
+191
-11
@@ -12,6 +12,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
|
||||
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
@@ -42,6 +44,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
@@ -58,6 +62,9 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class PdaPalletOperationService {
|
||||
@Resource private PdaPalletOperationMapper mapper;
|
||||
@Resource private PdaPalletOperationRepository mutationRepository;
|
||||
@Resource private PhysicalPalletSerialPort physicalPalletSerial;
|
||||
private Clock mutationClock = Clock.systemUTC();
|
||||
@Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@@ -75,11 +82,15 @@ public class PdaPalletOperationService {
|
||||
private TransactionTemplate transaction;
|
||||
private TransactionTemplate newTransaction;
|
||||
|
||||
/** 仓内组成预读和串行点后的复读使用RC,意图记录仍保留独立事务。 */
|
||||
@Resource
|
||||
void configureTransactions(PlatformTransactionManager manager) {
|
||||
transaction = new TransactionTemplate(manager);
|
||||
// 共同串行点之后必须看到其他事务的新成员,不能沿用MySQL默认RR的预读快照。
|
||||
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
|
||||
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
newTransaction = new TransactionTemplate(manager, definition);
|
||||
}
|
||||
|
||||
@@ -218,6 +229,7 @@ public class PdaPalletOperationService {
|
||||
false));
|
||||
}
|
||||
|
||||
/** 换标组托沿真实申请及箱贡献改绑,三个分支均先冻结完整实托组成。 */
|
||||
public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.RelabelDisassemble request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE",
|
||||
@@ -288,6 +300,8 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
if (source.palletId() == target.palletId()) {
|
||||
return transaction.execute(status -> {
|
||||
lockMutationComposition(identity, application, pallets);
|
||||
requireCurrentRelabelItems(application,selected);
|
||||
var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo());
|
||||
requireRelabelWork(identity, lockedApp);
|
||||
long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId());
|
||||
@@ -359,6 +373,7 @@ public class PdaPalletOperationService {
|
||||
reason, relabelPlan);
|
||||
}
|
||||
|
||||
/** 原生退货及无来源托盘的箱绑定共用冻结门禁,远端失败在原事务持锁期间补偿。 */
|
||||
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
|
||||
RelabelApplication application, List<RelabelItem> selected,
|
||||
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
|
||||
@@ -371,7 +386,7 @@ public class PdaPalletOperationService {
|
||||
long quantity = 0;
|
||||
List<ReturnInventoryCarton> cartons = new ArrayList<>();
|
||||
for (RelabelItem item : selected) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|
||||
|| (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode()))
|
||||
: !Objects.equals(carton.getPalletCode(), source.palletCode()))
|
||||
@@ -388,9 +403,19 @@ public class PdaPalletOperationService {
|
||||
String requestHash = hash(request);
|
||||
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
|
||||
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
|
||||
var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
|
||||
PhysicalJournal journal = new PhysicalJournal();
|
||||
Boolean[] compensationResult = { null };
|
||||
try {
|
||||
return transaction.execute(status -> {
|
||||
Map<String,PalletPhysicalGateway.PalletReference> pallets = new LinkedHashMap<>();
|
||||
if (source != null) pallets.put(source.palletCode(), source);
|
||||
pallets.put(target.palletCode(), target);
|
||||
lockMutationComposition(identity, application, pallets);
|
||||
requireCurrentRelabelItems(application,selected);
|
||||
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
|
||||
// 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。
|
||||
try {
|
||||
applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal);
|
||||
for (int index = 0; index < cartons.size(); index++) {
|
||||
RelabelItem item = selected.get(index);
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
@@ -423,10 +448,16 @@ public class PdaPalletOperationService {
|
||||
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
|
||||
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
boolean restored = compensate(identity.overseasWarehouseId(),journal.confirmed);
|
||||
compensationResult[0] = restored && !journal.awaitingReceipt;
|
||||
if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,不能确认已补偿"));
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
try { physical.compensate(identity.overseasWarehouseId(), transition); }
|
||||
catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); }
|
||||
boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty();
|
||||
fail(operation.getId(),failure,compensated);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -519,6 +550,7 @@ public class PdaPalletOperationService {
|
||||
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
|
||||
}
|
||||
|
||||
/** 所有托盘变更在库存锁之前冻结源目标完整组成,保留幂等与原事务持锁补偿。 */
|
||||
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
|
||||
Object request, String reason, Plan plan) {
|
||||
String normalizedKey = required(key, "幂等键不能为空");
|
||||
@@ -526,9 +558,18 @@ public class PdaPalletOperationService {
|
||||
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
|
||||
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
|
||||
|
||||
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
|
||||
PhysicalJournal journal = new PhysicalJournal();
|
||||
List<PalletPhysicalGateway.Transition> transitions = journal.confirmed;
|
||||
Boolean[] compensationResult = { null };
|
||||
try {
|
||||
PalletOperationResponses.Operation result = transaction.execute(status -> {
|
||||
lockMutationComposition(identity, plan.relabelMutation() == null ? null : plan.relabelMutation().application(), plan.pallets());
|
||||
if (plan.relabelMutation() != null) {
|
||||
var selected = plan.relabelMutation().itemsByPosition().values().stream().flatMap(List::stream).toList();
|
||||
requireCurrentRelabelItems(plan.relabelMutation().application(),selected);
|
||||
for (var move : plan.moves()) requireRelabelDestination(identity.overseasWarehouseId(), selected,
|
||||
plan.pallets().get(move.targetPalletCode()));
|
||||
}
|
||||
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
|
||||
Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer(
|
||||
plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId());
|
||||
@@ -547,18 +588,27 @@ public class PdaPalletOperationService {
|
||||
requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (PalletChange change : plan.changes()) {
|
||||
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
|
||||
applyPhysical(operation,identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
|
||||
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
|
||||
change.pallet().palletId()), reason));
|
||||
change.pallet().palletId()), reason,journal);
|
||||
}
|
||||
saveTransitions(operation.getId(), transitions);
|
||||
return mutate(identity, operation, plan, normalizedKey, requestHash, reason);
|
||||
} catch (RuntimeException failure) {
|
||||
// 普通及换标操作都持有共同串行点,不能先解锁再补偿旧的物理组成。
|
||||
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
|
||||
compensationResult[0] = compensated && !journal.awaitingReceipt;
|
||||
if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,局部补偿不代表整体恢复"));
|
||||
if (!compensated) failure.addSuppressed(new IllegalStateException("实托流转补偿未完成"));
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
|
||||
// 未进入物理阶段时没有远端变更;提交阶段异常不能解锁后自动回放旧补偿。
|
||||
boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty();
|
||||
fail(operation.getId(), failure, compensated);
|
||||
throw failure;
|
||||
}
|
||||
@@ -620,6 +670,97 @@ public class PdaPalletOperationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 首次申请锁前收齐闭包;申请、订单、实托共同串行点之后才允许读取库存并改绑。 */
|
||||
private void lockMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source,
|
||||
Map<String,PalletPhysicalGateway.PalletReference> pallets) {
|
||||
var seeds = new java.util.TreeMap<Long,String>();
|
||||
pallets.values().forEach(pallet -> seeds.put(pallet.palletId(),pallet.palletCode()));
|
||||
var before = readMutationComposition(identity,source,seeds);
|
||||
String expected = mutationCompositionIdentity(before);
|
||||
var numbers = new java.util.TreeSet<String>();
|
||||
before.composition().applications().forEach(app -> numbers.add(app.getRelabelNo()));
|
||||
if (source != null && !numbers.contains(source.getRelabelNo())) throw conflict("换标申请的真实组成未完整返回");
|
||||
for (String number : numbers) if (!mutationRepository.lockApplication(number))
|
||||
throw conflict("换标申请身份已变化,请重新读取完整组成");
|
||||
var orders = new java.util.TreeMap<Long,PdaPalletOperationMapper.MutationOrder>();
|
||||
for (var order : before.composition().orders()) {
|
||||
if (order.orderId() == null || order.orderId() <= 0 || order.customerId() == null || order.customerId().isBlank())
|
||||
throw conflict("实托来源缺少真实原始订单身份,不能改绑");
|
||||
var previous = orders.putIfAbsent(order.orderId(),order);
|
||||
if (previous != null && !previous.customerId().equals(order.customerId()))
|
||||
throw conflict("实托来源订单客户归属不一致");
|
||||
}
|
||||
for (var order : orders.values()) if (!mutationRepository.lockOrder(order))
|
||||
throw conflict("实托来源订单不存在或归属已变化");
|
||||
physicalPalletSerial.lock(List.copyOf(before.pallets().keySet()));
|
||||
var current = readMutationComposition(identity,source,seeds);
|
||||
// 共同串行点之后发现新申请、订单、实托或贡献即整笔拒绝,不追加乱序锁。
|
||||
if (!expected.equals(mutationCompositionIdentity(current))) throw conflict("实托组成已变化,请重新读取后组托");
|
||||
for (var application : current.composition().applications()) requireUnallocatedRelabel(application);
|
||||
if (source != null) {
|
||||
var currentSource = current.composition().applications().stream()
|
||||
.filter(app -> source.getRelabelNo().equals(app.getRelabelNo())).findFirst()
|
||||
.orElseThrow(() -> conflict("换标申请身份已变化"));
|
||||
requireRelabelWork(identity,currentSource);
|
||||
}
|
||||
var now = LocalDateTime.ofInstant(mutationClock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);
|
||||
if (mutationRepository.transportClaims(List.copyOf(current.pallets().keySet()),now) != 0)
|
||||
throw conflict("实托已进入排车或有效草稿,请先解除运输占用后再组托");
|
||||
}
|
||||
|
||||
/** 解析原生退货关联的真实托盘码,再重新展开全客户组成,绝不猜测资源ID。 */
|
||||
private MutationComposition readMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source,
|
||||
Map<Long,String> seeds) {
|
||||
var resolved = new java.util.TreeMap<>(seeds);
|
||||
PdaPalletOperationRepository.Composition composition;
|
||||
boolean changed;
|
||||
do {
|
||||
composition = mutationRepository.composition(String.valueOf(identity.overseasWarehouseId()),resolved,source);
|
||||
changed = false;
|
||||
for (var pallet : composition.pallets()) {
|
||||
if (pallet.palletCode() == null || pallet.palletCode().isBlank()) throw conflict("关联实托缺少真实托盘码");
|
||||
Long id = pallet.palletId();
|
||||
if (id == null) {
|
||||
var known = resolved.entrySet().stream().filter(entry -> entry.getValue().equals(pallet.palletCode())).findFirst();
|
||||
id = known.isPresent() ? known.get().getKey() : requirePallet(identity.overseasWarehouseId(),pallet.palletCode()).palletId();
|
||||
}
|
||||
if (id <= 0) throw conflict("关联实托缺少真实资源身份");
|
||||
var previous = resolved.putIfAbsent(id,pallet.palletCode());
|
||||
if (previous != null && !previous.equals(pallet.palletCode())) throw conflict("关联实托身份与托盘码不一致");
|
||||
changed |= previous == null;
|
||||
}
|
||||
} while (changed);
|
||||
return new MutationComposition(composition,java.util.Collections.unmodifiableSortedMap(resolved));
|
||||
}
|
||||
|
||||
/** 比较组成身份与贡献,不把预读对象或展示首行当作已锁定事实。 */
|
||||
private String mutationCompositionIdentity(MutationComposition value) {
|
||||
var applications = value.composition().applications().stream().map(app -> java.util.Arrays.asList(
|
||||
app.getId(),app.getRelabelNo(),app.getCustomerId(),app.getOverseasWarehouseId(),app.getLastlegOrderId(),
|
||||
app.getStatus(),app.getFulfillmentStatus(),app.getOutboundOrderNo())).map(Object::toString).sorted().toList();
|
||||
return write(Map.of("applications",applications,"orders",value.composition().orders().stream().map(Object::toString).sorted().toList(),
|
||||
"pallets",value.pallets(),"facts",value.composition().facts().stream().map(Object::toString).sorted().toList()));
|
||||
}
|
||||
|
||||
/** 本轮完整组成和已解析的全局实托集合。 */
|
||||
private record MutationComposition(PdaPalletOperationRepository.Composition composition, java.util.SortedMap<Long,String> pallets) { }
|
||||
|
||||
/** 申请锁内复验请求预读的逐箱指针,不能用改绑前的旧明细覆盖当前贡献。 */
|
||||
private void requireCurrentRelabelItems(RelabelApplication application,List<RelabelItem> selected) {
|
||||
for (var item : selected) {
|
||||
var current = item.getId() == null ? null : relabelItems.selectById(item.getId());
|
||||
if (current == null || !Objects.equals(current.getApplicationId(),application.getId())
|
||||
|| !Objects.equals(current.getCustomerId(),application.getCustomerId())
|
||||
|| !Objects.equals(current.getSourceInventoryPositionId(),item.getSourceInventoryPositionId())
|
||||
|| !Objects.equals(current.getReturnCartonId(),item.getReturnCartonId())
|
||||
|| !Objects.equals(current.getCartonMark(),item.getCartonMark())
|
||||
|| !Objects.equals(current.getNotifiedPackageCount(),item.getNotifiedPackageCount())
|
||||
|| !Objects.equals(current.getDestinationFbaWarehouseCode(),item.getDestinationFbaWarehouseCode()))
|
||||
throw conflict("换标箱的真实贡献或绑定已变化,请重新读取");
|
||||
}
|
||||
}
|
||||
|
||||
/** 已收齐全部申请锁后重核本次申请,重复取得同一申请不改变全局锁序。 */
|
||||
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
|
||||
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
|
||||
if (locked != null) requireUnallocatedRelabel(locked);
|
||||
@@ -1016,10 +1157,49 @@ public class PdaPalletOperationService {
|
||||
return read(existing).asReplay();
|
||||
}
|
||||
|
||||
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
|
||||
/** 每次远端调用先保存完整意图,只有匹配的APPLIED回执才能清除当前未决事实。 */
|
||||
private void applyPhysical(PdaPalletOperation operation,long warehouseId,PalletPhysicalGateway.PalletReference pallet,
|
||||
String targetStatus,Long targetLocationId,String key,String reason,PhysicalJournal journal) {
|
||||
var attempt = new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(),
|
||||
pallet.status(),pallet.locationId(),targetStatus,targetLocationId,null);
|
||||
journal.attempts.add(attempt);
|
||||
savePhysicalAttempts(operation.getId(),journal.attempts);
|
||||
// 从进入POST调用到有效回执之间均可能已在远端提交,不能按异常类型或HTTP码推定未写。
|
||||
journal.awaitingReceipt = true;
|
||||
var receipt = physical.apply(warehouseId,pallet,targetStatus,targetLocationId,key,reason);
|
||||
Long effectiveLocation = targetLocationId == null ? pallet.locationId() : targetLocationId;
|
||||
if (receipt == null || !key.equals(receipt.operationKey()) || receipt.warehouseId()!=warehouseId
|
||||
|| receipt.palletId()!=pallet.palletId() || !Objects.equals(receipt.palletCode(),pallet.palletCode())
|
||||
|| !Objects.equals(receipt.beforeStatus(),pallet.status()) || receipt.beforeVersion()!=pallet.version()
|
||||
|| !Objects.equals(receipt.beforeLocationId(),pallet.locationId())
|
||||
|| !Objects.equals(receipt.afterStatus(),targetStatus) || !Objects.equals(receipt.afterLocationId(),effectiveLocation)
|
||||
|| (long)receipt.afterVersion()!=(long)pallet.version()+1 || !"APPLIED".equals(receipt.operationStatus()))
|
||||
throw new IllegalStateException("实托流转未返回匹配本次意图的APPLIED回执,请保留原请求核对");
|
||||
journal.confirmed.add(receipt);
|
||||
journal.awaitingReceipt = false;
|
||||
journal.attempts.set(journal.attempts.size()-1,new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(),
|
||||
pallet.status(),pallet.locationId(),targetStatus,targetLocationId,receipt));
|
||||
savePhysicalAttempts(operation.getId(),journal.attempts);
|
||||
}
|
||||
|
||||
/** 已知回执与尚未确认的原请求分别保留,不用占位Transition冒充成功证据。 */
|
||||
private record PhysicalAttempt(String operationKey,long warehouseId,long palletId,String palletCode,int expectedVersion,
|
||||
String beforeStatus,Long beforeLocationId,String targetStatus,Long targetLocationId,
|
||||
PalletPhysicalGateway.Transition receipt) { }
|
||||
|
||||
/** 本次作业所有意图和已确认回执;未决当前调用不属于可补偿回执集合。 */
|
||||
private static final class PhysicalJournal {
|
||||
private final List<PhysicalAttempt> attempts = new ArrayList<>();
|
||||
private final List<PalletPhysicalGateway.Transition> confirmed = new ArrayList<>();
|
||||
private boolean awaitingReceipt;
|
||||
}
|
||||
|
||||
/** 意图证据独立保存,业务事务回滚或进程中断不能抹去已发远端请求。 */
|
||||
private void savePhysicalAttempts(long operationId, List<PhysicalAttempt> attempts) {
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
PdaPalletOperation operation = mapper.selectById(operationId);
|
||||
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
|
||||
if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求");
|
||||
operation.setRemoteTransitionsJson(write(attempts)); operation.setUpdatedAt(LocalDateTime.now());
|
||||
mapper.updateById(operation);
|
||||
});
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.relabel.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */
|
||||
@Mapper
|
||||
public interface RelabelDispatchSourceMapper {
|
||||
String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
|
||||
|
||||
/** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */
|
||||
@Select("<script>SELECT DISTINCT i.application_id FROM ll_relabel_item i "
|
||||
+ "JOIN ll_inventory_position p ON p.id=i.source_inventory_position_id "
|
||||
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + PALLETS
|
||||
+ " ORDER BY i.application_id</script>")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Long> applicationIds(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds);
|
||||
|
||||
/** 读取托盘全部库存位置,保留未被某申请引用的位置。 */
|
||||
@Select("<script>SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} AND pallet_id IN "
|
||||
+ PALLETS + " ORDER BY id</script>")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<InventoryPosition> palletPositions(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds);
|
||||
|
||||
/** 读取原申请身份和履约状态。 */
|
||||
@Select("SELECT * FROM ll_relabel_application WHERE id=#{id}")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
RelabelApplication application(@Param("id") Long id);
|
||||
|
||||
/** 读取申请全部明细,防止只选择同申请的一部分实托。 */
|
||||
@Select("SELECT * FROM ll_relabel_item WHERE application_id=#{id} ORDER BY id")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<RelabelItem> items(@Param("id") Long id);
|
||||
|
||||
/** 读取当前任务及复核轮次,不按任务状态筛选。 */
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE application_id=#{id} ORDER BY id")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<PdaRelabelTask> tasks(@Param("id") Long id);
|
||||
|
||||
/** 独立读取作业历史,避免与逐箱事实连接后放大贡献。 */
|
||||
@Select("SELECT * FROM ll_relabel_pda_history WHERE application_id=#{id} ORDER BY created_at,id")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<PdaRelabelHistory> histories(@Param("id") Long id);
|
||||
|
||||
/** 独立读取全部逐箱事实,由业务层识别当前轮次与归档记录。 */
|
||||
@Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE application_id=#{id} ORDER BY id")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<PdaRelabelUnitFact> facts(@Param("id") Long id);
|
||||
|
||||
/** 读取明细当前位置,不在此处取得正式库存锁。 */
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE id=#{id}")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
InventoryPosition position(@Param("id") Long id);
|
||||
|
||||
/** 仅按申请业务号取得原申请行锁,顺序由业务层统一排列。 */
|
||||
@Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{relabelNo} ORDER BY id FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Long> lockApplication(@Param("relabelNo") String relabelNo);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.relabel.port;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 换标域向排车提供原始组成事实,不读取排车账本或代替排车锁定库存。 */
|
||||
public interface RelabelDispatchSourcePort {
|
||||
/** 当前作业轮次的逐件成功或异常事实;未知件数保持空值。 */
|
||||
record UnitFact(Long id, String oldLabel, String newLabel, String result, Integer quantity) { }
|
||||
|
||||
/** 原申请身份、内部作业状态、履约订单及客户确认轮次。 */
|
||||
record Application(Long applicationId, String relabelNo, String customerId,
|
||||
Long fulfillmentOrderId, String status, String fulfillmentStatus,
|
||||
String deliveryMethod, String roundKey, LocalDateTime updatedAt,
|
||||
LocalDateTime customerConfirmedAt) { }
|
||||
|
||||
/** 按申请与任务明细聚合的实际绑定贡献,原货源始终取明细冻结字段。 */
|
||||
record BoundSource(Long applicationId, String relabelNo, Long itemId, String customerId,
|
||||
String warehouseId, Long warehousePalletId, Long inventoryPositionId,
|
||||
Long inventoryBalanceId, Long sourceInventoryBalanceId,
|
||||
Long sourceInboundOrderId, String sourceInboundOrderNo,
|
||||
String sourceInboundCode, long notifiedPackageCount,
|
||||
long boundPackageCount, Long productQuantity, String bindingBasisHash,
|
||||
List<UnitFact> unitFacts) { }
|
||||
|
||||
/** 库存位置的原始箱数和版本,不在提供者内取得库存正式锁。 */
|
||||
record Position(Long positionId, String customerId, Long inventoryBalanceId,
|
||||
Long warehousePalletId, long availablePackages, long holdPackages,
|
||||
long allocatedPackages, long pickedPackages, long receivingPackages,
|
||||
long recordVersion) { }
|
||||
|
||||
/** 从实托扩展关联申请及全部绑定明细的组成快照,资格判断仍由消费方负责。 */
|
||||
record Snapshot(List<Application> applications, List<BoundSource> sources,
|
||||
List<Position> positions, String compositionVersion,
|
||||
boolean complete, List<String> blockReasons) { }
|
||||
|
||||
/** 只读取得完整原始组成,不按当前页面筛选申请或业务状态。 */
|
||||
Snapshot snapshot(String warehouseId, List<Long> warehousePalletIds);
|
||||
|
||||
/** 在调用方现存事务内按业务号稳定顺序一次锁齐原申请。 */
|
||||
void lockApplications(List<String> relabelNos);
|
||||
|
||||
/** 调用方持有申请锁后重新读取组成;库存位置仍由排车域统一锁定。 */
|
||||
Snapshot lockedSnapshot(String warehouseId, List<Long> warehousePalletIds);
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.lastleg.relabel.repository;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelDispatchSourceMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** 从请求实托递归扩展申请与全部关联实托,始终通过实际 Mapper 读写。 */
|
||||
@Repository
|
||||
public class RelabelDispatchSourceRepository {
|
||||
private final RelabelDispatchSourceMapper mapper;
|
||||
|
||||
/** 注入换标事实查询映射器。 */
|
||||
public RelabelDispatchSourceRepository(RelabelDispatchSourceMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** 单个申请独立读取的原始持久化事实,尚未进行业务资格判断。 */
|
||||
public record ApplicationRows(RelabelApplication application, List<RelabelItem> items,
|
||||
List<PdaRelabelTask> tasks, List<PdaRelabelHistory> histories,
|
||||
List<PdaRelabelUnitFact> facts) { }
|
||||
|
||||
/** 闭包中全部申请与库存位置;缺失数据作为不完整原因返回。 */
|
||||
public record Closure(List<ApplicationRows> applications, List<InventoryPosition> positions,
|
||||
List<String> blockReasons) { }
|
||||
|
||||
/** 扩展到固定点:实托上的全部申请、申请的全部明细、明细所在实托上的其他申请。 */
|
||||
public Closure closure(String warehouseId, List<Long> requestedPallets) {
|
||||
Set<Long> pending = new TreeSet<>(requestedPallets);
|
||||
Set<Long> visitedPallets = new LinkedHashSet<>();
|
||||
Set<Long> visitedApplications = new LinkedHashSet<>();
|
||||
Map<Long, InventoryPosition> positions = new TreeMap<>();
|
||||
List<ApplicationRows> applications = new ArrayList<>();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
while (!pending.isEmpty()) {
|
||||
List<Long> batch = List.copyOf(pending);
|
||||
pending.clear();
|
||||
visitedPallets.addAll(batch);
|
||||
mapper.palletPositions(warehouseId, batch).forEach(position -> positions.put(position.getId(), position));
|
||||
for (Long applicationId : mapper.applicationIds(warehouseId, batch)) {
|
||||
if (!visitedApplications.add(applicationId)) continue;
|
||||
RelabelApplication application = mapper.application(applicationId);
|
||||
if (application == null) {
|
||||
reasons.add("换标明细缺少所属申请");
|
||||
continue;
|
||||
}
|
||||
// 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。
|
||||
// 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。
|
||||
if ("CANCELLED".equals(application.getStatus())) continue;
|
||||
List<RelabelItem> items = mapper.items(applicationId);
|
||||
applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId),
|
||||
mapper.histories(applicationId), mapper.facts(applicationId)));
|
||||
for (RelabelItem item : items) {
|
||||
if (item.getSourceInventoryPositionId() == null) continue;
|
||||
InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
|
||||
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
|
||||
if (position == null) continue;
|
||||
positions.put(position.getId(), position);
|
||||
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
|
||||
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
|
||||
}
|
||||
}
|
||||
}
|
||||
applications.sort(java.util.Comparator.comparing(value -> value.application().getRelabelNo()));
|
||||
return new Closure(List.copyOf(applications), List.copyOf(positions.values()), List.copyOf(reasons));
|
||||
}
|
||||
|
||||
/** 按单一业务号锁定原申请,返回匹配行数以识别缺失或重复身份。 */
|
||||
public int lockApplication(String relabelNo) {
|
||||
return mapper.lockApplication(relabelNo).size();
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
|
||||
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository.ApplicationRows;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** 普通换标的原申请与实托组成提供者;业务资格和正式库存锁由排车消费方负责。 */
|
||||
@Service
|
||||
public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
|
||||
private final RelabelDispatchSourceRepository repository;
|
||||
private final ObjectMapper json;
|
||||
private final Object applicationLocks = new Object();
|
||||
|
||||
/** 注入持久化事实入口和历史解析器。 */
|
||||
public RelabelDispatchSourceService(RelabelDispatchSourceRepository repository, ObjectMapper json) {
|
||||
this.repository = repository;
|
||||
this.json = json.copy().findAndRegisterModules();
|
||||
}
|
||||
|
||||
/** 读取所有关联申请和库存组成,保留未确认、异常及其他不可用状态。 */
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Snapshot snapshot(String warehouseId, List<Long> warehousePalletIds) {
|
||||
return readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds));
|
||||
}
|
||||
|
||||
/** 一次锁齐按业务号排序的原申请,禁止在事务中追加另一批锁。 */
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void lockApplications(List<String> relabelNos) {
|
||||
requireTransaction();
|
||||
if (relabelNos == null || relabelNos.stream().anyMatch(no -> no == null || no.isBlank()))
|
||||
throw new IllegalArgumentException("换标申请业务号不能为空");
|
||||
Set<String> ordered = new TreeSet<>(relabelNos);
|
||||
Object previous = TransactionSynchronizationManager.getResource(applicationLocks);
|
||||
if (previous != null) {
|
||||
if (!previous.equals(ordered)) throw new IllegalStateException("本事务已锁定换标申请,不能追加或替换锁集合");
|
||||
return;
|
||||
}
|
||||
for (String relabelNo : ordered) {
|
||||
if (repository.lockApplication(relabelNo) != 1)
|
||||
throw new IllegalStateException("换标申请不存在或业务号不唯一:" + relabelNo);
|
||||
}
|
||||
TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered));
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
/** 挂起调用方事务时同步挂起锁凭证,避免新事务借用旧事务的锁。 */
|
||||
@Override
|
||||
public void suspend() {
|
||||
TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks);
|
||||
}
|
||||
|
||||
/** 恢复调用方事务时恢复该事务自己的锁凭证。 */
|
||||
@Override
|
||||
public void resume() {
|
||||
TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered));
|
||||
}
|
||||
|
||||
/** 事务结束后清除锁凭证,防止下一事务复用。 */
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 重读完整闭包,并拒绝申请锁集合以外的新组成,不抢先锁库存位置。 */
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public Snapshot lockedSnapshot(String warehouseId, List<Long> warehousePalletIds) {
|
||||
requireTransaction();
|
||||
Object resource = TransactionSynchronizationManager.getResource(applicationLocks);
|
||||
if (!(resource instanceof Set<?> locked)) throw new IllegalStateException("须先一次锁齐换标原申请");
|
||||
Snapshot current = readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds));
|
||||
if (current.applications().stream().anyMatch(app -> !locked.contains(app.relabelNo())))
|
||||
throw new IllegalStateException("换标实托组成已变化,请重新读取并锁定全部申请");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 从闭包生成按申请、明细聚合的事实,不连接逐箱和历史表放大数量。 */
|
||||
private Snapshot readSnapshot(String warehouseId, List<Long> palletIds) {
|
||||
var closure = repository.closure(warehouseId, palletIds);
|
||||
Set<String> reasons = new LinkedHashSet<>(closure.blockReasons());
|
||||
Map<Long, InventoryPosition> positions = new HashMap<>();
|
||||
closure.positions().forEach(position -> positions.put(position.getId(), position));
|
||||
List<Application> applications = new ArrayList<>();
|
||||
List<BoundSource> sources = new ArrayList<>();
|
||||
for (ApplicationRows rows : closure.applications()) {
|
||||
RelabelApplication app = rows.application();
|
||||
PdaRelabelTask task = rows.tasks().size() == 1 ? rows.tasks().get(0) : null;
|
||||
if (task == null || !Objects.equals(task.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(task.getRelabelNo(), app.getRelabelNo())
|
||||
|| !Objects.equals(Objects.toString(task.getOverseasWarehouseId(), null), app.getOverseasWarehouseId()))
|
||||
reasons.add(app.getRelabelNo() + ":作业任务缺失或归属不一致");
|
||||
if (!Objects.equals(warehouseId, app.getOverseasWarehouseId()))
|
||||
reasons.add(app.getRelabelNo() + ":申请与实托不属于同一仓库");
|
||||
List<PdaRelabelHistory> histories = currentHistories(rows, task, reasons);
|
||||
LocalDateTime archivedAt = rows.histories().stream().filter(h -> "REWORK_ARCHIVE".equals(h.getAction()))
|
||||
.filter(h -> task != null && Objects.equals(h.getTaskId(), task.getId())
|
||||
&& Objects.equals(h.getCustomerId(), app.getCustomerId()))
|
||||
.map(PdaRelabelHistory::getCreatedAt).filter(Objects::nonNull).max(LocalDateTime::compareTo).orElse(null);
|
||||
// 客户确认会重写任务 reviewedAt;作业轮次只能取最后返工边界后的生产者历史。
|
||||
String round = currentRound(histories, archivedAt, reasons, app.getRelabelNo());
|
||||
applications.add(new Application(app.getId(), app.getRelabelNo(), app.getCustomerId(),
|
||||
app.getLastlegOrderId(), app.getStatus(), app.getFulfillmentStatus(), app.getDeliveryMethod(),
|
||||
round, app.getUpdatedAt(), app.getCustomerConfirmedAt()));
|
||||
List<PdaRelabelUnitFact> facts = rows.facts().stream()
|
||||
.filter(f -> !"REWORK_ARCHIVED".equals(f.getResult()))
|
||||
.filter(f -> task != null && Objects.equals(f.getTaskId(), task.getId())
|
||||
&& Objects.equals(f.getCustomerId(), app.getCustomerId()))
|
||||
.filter(f -> archivedAt == null || (f.getOccurredAt() != null && f.getOccurredAt().isAfter(archivedAt)))
|
||||
.toList();
|
||||
if (rows.facts().stream().filter(f -> !"REWORK_ARCHIVED".equals(f.getResult())).count() != facts.size())
|
||||
reasons.add(app.getRelabelNo() + ":存在归属不一致或旧轮次未归档的作业事实");
|
||||
// 独立的申请、明细聚合保留成功与异常事实;不得用成功数冒充绑定数。
|
||||
for (RelabelItem item : rows.items()) {
|
||||
InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
|
||||
List<PdaRelabelUnitFact> itemFacts = facts.stream()
|
||||
.filter(f -> Objects.equals(f.getItemId(), item.getId())).toList();
|
||||
boolean bound = bindingMatches(item, position, histories, round, reasons, app.getRelabelNo());
|
||||
long notified = number(item.getNotifiedPackageCount());
|
||||
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|
||||
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
|
||||
bound = false;
|
||||
}
|
||||
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||
reasons.add(app.getRelabelNo() + ":退货原生换标须使用退货组成提供者");
|
||||
bound = false;
|
||||
}
|
||||
if (notified <= 0) reasons.add(app.getRelabelNo() + ":通知箱数无效");
|
||||
if (!bound) reasons.add(app.getRelabelNo() + ":明细缺少当前轮次的有效实托绑定");
|
||||
String basis = hash(java.util.Arrays.asList(app, task, item, position, histories, itemFacts));
|
||||
sources.add(new BoundSource(app.getId(), app.getRelabelNo(), item.getId(), item.getCustomerId(),
|
||||
position == null ? warehouseId : position.getOverseasWarehouseId(),
|
||||
position == null ? null : position.getPalletId(), item.getSourceInventoryPositionId(),
|
||||
position == null ? null : position.getBalanceId(), item.getSourceInventoryBalanceId(),
|
||||
item.getSourceInboundOrderId(), item.getSourceInboundOrderNo(), item.getSourceInboundCode(),
|
||||
notified, bound ? notified : 0, productQuantity(item, itemFacts, histories, round), basis,
|
||||
itemFacts.stream().map(f -> new UnitFact(f.getId(), f.getOldLabel(), f.getNewLabel(),
|
||||
f.getResult(), f.getQuantity())).toList()));
|
||||
}
|
||||
if (rows.items().isEmpty()) reasons.add(app.getRelabelNo() + ":申请明细为空");
|
||||
if (facts.stream().anyMatch(f -> rows.items().stream().noneMatch(i -> Objects.equals(i.getId(), f.getItemId()))))
|
||||
reasons.add(app.getRelabelNo() + ":作业事实缺少所属明细");
|
||||
}
|
||||
Map<Long, Long> contributions = new HashMap<>();
|
||||
sources.forEach(source -> {
|
||||
if (source.inventoryPositionId() != null)
|
||||
contributions.merge(source.inventoryPositionId(), source.boundPackageCount(), Math::addExact);
|
||||
});
|
||||
// 多申请共用位置必须合计比较 HOLD,不能让每张申请分别通过后重复消费。
|
||||
contributions.forEach((id, total) -> {
|
||||
InventoryPosition position = positions.get(id);
|
||||
if (position != null && total > number(position.getHoldPackages()))
|
||||
reasons.add("实托绑定总箱数超过该位置实际 HOLD 箱数");
|
||||
});
|
||||
List<Position> positionFacts = closure.positions().stream().map(p -> new Position(p.getId(), p.getCustomerId(),
|
||||
p.getBalanceId(), p.getPalletId(), number(p.getAvailablePackages()), number(p.getHoldPackages()),
|
||||
number(p.getAllocatedPackages()), number(p.getPickedPackages()), number(p.getReceivingPackages()),
|
||||
number(p.getVersion()))).toList();
|
||||
String version = hash(List.of(warehouseId, palletIds, closure, applications, sources, positionFacts));
|
||||
return new Snapshot(List.copyOf(applications), List.copyOf(sources), positionFacts, version,
|
||||
reasons.isEmpty(), List.copyOf(reasons));
|
||||
}
|
||||
|
||||
/** 清除返工归档前历史,只保留同客户、同任务的原始当前历史。 */
|
||||
private List<PdaRelabelHistory> currentHistories(ApplicationRows rows, PdaRelabelTask task, Set<String> reasons) {
|
||||
List<PdaRelabelHistory> current = new ArrayList<>();
|
||||
for (PdaRelabelHistory history : rows.histories()) {
|
||||
if (task == null || !Objects.equals(history.getCustomerId(), rows.application().getCustomerId())
|
||||
|| !Objects.equals(history.getTaskId(), task.getId())) {
|
||||
reasons.add(rows.application().getRelabelNo() + ":作业历史归属不一致");
|
||||
continue;
|
||||
}
|
||||
if ("REWORK_ARCHIVE".equals(history.getAction())) current.clear();
|
||||
else current.add(history);
|
||||
}
|
||||
return List.copyOf(current);
|
||||
}
|
||||
|
||||
/** 从实际完工、绑定及扫码历史识别轮次;返工前轮次即使出现在边界后也不能复用。 */
|
||||
private String currentRound(List<PdaRelabelHistory> histories, LocalDateTime archivedAt,
|
||||
Set<String> reasons, String relabelNo) {
|
||||
Set<String> recorded = new LinkedHashSet<>();
|
||||
String completed = null;
|
||||
for (PdaRelabelHistory history : histories) {
|
||||
if (!Set.of("COMPLETE", "PALLET_BIND", "SKU_SCAN", "MIXED_CARTON", "MIXED_SKU", "RETURN_PACK")
|
||||
.contains(Objects.toString(history.getAction(), ""))) continue;
|
||||
JsonNode data = detail(history, reasons, relabelNo);
|
||||
String round = data.path("COMPLETE".equals(history.getAction()) ? "roundKey" : "round").asText(null);
|
||||
if (round == null || round.isBlank()) {
|
||||
reasons.add(relabelNo + ":作业历史缺少轮次");
|
||||
continue;
|
||||
}
|
||||
if (!afterReworkBoundary(round, archivedAt)) {
|
||||
reasons.add(relabelNo + ":返工后存在旧轮次作业历史");
|
||||
continue;
|
||||
}
|
||||
recorded.add(round);
|
||||
if ("COMPLETE".equals(history.getAction())) completed = round;
|
||||
}
|
||||
if (recorded.size() != 1) {
|
||||
reasons.add(relabelNo + (recorded.isEmpty() ? ":未找到当前作业轮次" : ":当前作业历史轮次不一致"));
|
||||
return null;
|
||||
}
|
||||
return completed == null ? recorded.iterator().next() : completed;
|
||||
}
|
||||
|
||||
/** 返工轮次由生产者写入复核时间文本,允许数据库毫秒精度下与归档边界同刻。 */
|
||||
private boolean afterReworkBoundary(String round, LocalDateTime archivedAt) {
|
||||
if (archivedAt == null) return true;
|
||||
try {
|
||||
return !LocalDateTime.parse(round).isBefore(archivedAt);
|
||||
} catch (DateTimeParseException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 专用绑定必须同时命中当前轮次、箱唛与当前位置实托码。 */
|
||||
private boolean bindingMatches(RelabelItem item, InventoryPosition position, List<PdaRelabelHistory> histories,
|
||||
String round, Set<String> reasons, String relabelNo) {
|
||||
if (round == null) return false;
|
||||
boolean bound = false;
|
||||
for (PdaRelabelHistory history : histories) {
|
||||
if (!"PALLET_BIND".equals(history.getAction())) continue;
|
||||
JsonNode detail = detail(history, reasons, relabelNo);
|
||||
if (!Objects.equals(round, detail.path("round").asText(null))) continue;
|
||||
if (contains(detail.path("cartonMarks"), item.getCartonMark()))
|
||||
bound = position != null && contains(detail.path("targetPallets"), position.getPalletNo());
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
|
||||
/** 仅以本轮实际 SKU 扫描确认件数;纯箱标及缺少逐 SKU 事实保持未知。 */
|
||||
private Long productQuantity(RelabelItem item, List<PdaRelabelUnitFact> facts,
|
||||
List<PdaRelabelHistory> histories, String round) {
|
||||
if (round == null || !Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(Objects.toString(item.getOperationType(), ""))
|
||||
|| facts.stream().noneMatch(f -> "RELABELLED".equals(f.getResult()))) return null;
|
||||
try {
|
||||
JsonNode expected = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]"));
|
||||
if (!expected.isArray() || expected.isEmpty()) return null;
|
||||
Map<Integer, Long> scanned = new HashMap<>();
|
||||
for (PdaRelabelHistory history : histories) {
|
||||
if (!"SKU_SCAN".equals(history.getAction())) continue;
|
||||
JsonNode data = json.readTree(history.getDetailJson());
|
||||
if (!Objects.equals(round, data.path("round").asText(null))
|
||||
|| !Objects.equals(item.getId().toString(), data.path("itemId").asText())) continue;
|
||||
int index = data.path("skuIndex").asInt(-1);
|
||||
long quantity = data.path("quantity").asLong(-1);
|
||||
if (index < 0 || index >= expected.size() || quantity <= 0 || scanned.putIfAbsent(index, quantity) != null)
|
||||
return null;
|
||||
}
|
||||
if (scanned.size() != expected.size()) return null;
|
||||
long total = 0;
|
||||
for (int index = 0; index < expected.size(); index++) {
|
||||
if (expected.get(index).path("quantity").asLong(-1) != scanned.get(index)) return null;
|
||||
total = Math.addExact(total, scanned.get(index));
|
||||
}
|
||||
return total;
|
||||
} catch (JsonProcessingException | ArithmeticException invalid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析绑定历史;损坏记录显式阻止宣称快照完整。 */
|
||||
private JsonNode detail(PdaRelabelHistory history, Set<String> reasons, String relabelNo) {
|
||||
try {
|
||||
JsonNode parsed = json.readTree(Objects.toString(history.getDetailJson(), ""));
|
||||
if (parsed != null && parsed.isObject()) return parsed;
|
||||
} catch (JsonProcessingException invalid) {
|
||||
reasons.add(relabelNo + ":实托绑定历史无法读取");
|
||||
}
|
||||
reasons.add(relabelNo + ":实托绑定历史缺少对象结构");
|
||||
return json.createObjectNode();
|
||||
}
|
||||
|
||||
/** 严格按原业务码判断历史中的箱唛或实托码。 */
|
||||
private boolean contains(JsonNode values, String expected) {
|
||||
if (expected == null || !values.isArray()) return false;
|
||||
for (JsonNode value : values) if (expected.equals(value.asText())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 统一组成和绑定摘要,保留原始位置版本、申请更新及返工历史变化。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
byte[] encoded = json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(encoded));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException failure) {
|
||||
throw new IllegalStateException("无法生成换标组成版本", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** 实托集合必须明确且仅含有效身份;排序去重使相同选择拥有稳定摘要。 */
|
||||
private List<Long> normalizePallets(String warehouseId, List<Long> ids) {
|
||||
if (warehouseId == null || warehouseId.isBlank() || ids == null || ids.isEmpty()
|
||||
|| ids.stream().anyMatch(id -> id == null || id <= 0))
|
||||
throw new IllegalArgumentException("仓库和实托集合不能为空");
|
||||
return List.copyOf(new TreeSet<>(ids));
|
||||
}
|
||||
|
||||
/** 空数量只作为零事实输出,不推导商品件数。 */
|
||||
private long number(Number value) {
|
||||
return value == null ? 0 : value.longValue();
|
||||
}
|
||||
|
||||
/** 直接调用同样要求调用方事务,避免绕过 Spring 代理后无事务取锁。 */
|
||||
private void requireTransaction() {
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()
|
||||
|| !TransactionSynchronizationManager.isSynchronizationActive())
|
||||
throw new IllegalStateException("换标申请锁与锁后重读必须加入调用方现存事务");
|
||||
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly())
|
||||
throw new IllegalStateException("换标申请锁必须加入调用方写事务");
|
||||
}
|
||||
}
|
||||
@@ -1986,7 +1986,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
|
||||
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key),
|
||||
KEY idx_ll_dispatch_item_order (customer_id,order_id),
|
||||
KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code),
|
||||
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
|
||||
@@ -1998,12 +1998,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_item_source (
|
||||
id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键',
|
||||
dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID',
|
||||
dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID',
|
||||
relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID',
|
||||
relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID',
|
||||
relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID',
|
||||
inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID',
|
||||
inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID',
|
||||
source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空',
|
||||
source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空',
|
||||
source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照',
|
||||
source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照',
|
||||
notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功',
|
||||
package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数',
|
||||
product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空',
|
||||
binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要',
|
||||
unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id),
|
||||
KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id),
|
||||
KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id),
|
||||
CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0),
|
||||
CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0)
|
||||
) ENGINE=InnoDB COMMENT='换标逻辑来源实际贡献不可变快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard (
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间',
|
||||
PRIMARY KEY (warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '物理实托占用主键',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID',
|
||||
active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空',
|
||||
dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥',
|
||||
draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥',
|
||||
draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID',
|
||||
operator_id VARCHAR(128) NULL COMMENT '占用操作人ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态',
|
||||
expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空',
|
||||
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
|
||||
released_at DATETIME(3) NULL COMMENT '释放时间',
|
||||
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 '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id),
|
||||
KEY idx_dispatch_physical_order (dispatch_order_id,status),
|
||||
KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status),
|
||||
CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)),
|
||||
CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL))
|
||||
) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
physical_lock_id BIGINT NULL COMMENT '实际物理实托占用ID,非物理来源为空',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键',
|
||||
active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
|
||||
active_warehouse_pallet_id BIGINT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一',
|
||||
plan_id BIGINT NULL COMMENT '入仓计划ID',
|
||||
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
|
||||
@@ -2021,10 +2079,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
|
||||
UNIQUE KEY uk_ll_dispatch_active_warehouse_pallet (active_warehouse_pallet_id),
|
||||
KEY idx_dispatch_logical_physical (physical_lock_id,status),
|
||||
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
|
||||
KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status)
|
||||
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
|
||||
) ENGINE=InnoDB COMMENT='客户逻辑运输来源占用';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_draft (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识',
|
||||
@@ -2137,8 +2195,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order (
|
||||
product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
|
||||
return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
|
||||
dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥',
|
||||
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
|
||||
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1),
|
||||
KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id),
|
||||
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1),
|
||||
|
||||
|
||||
PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 共用车次准备与拼车连续处理
|
||||
title: 外接登记与整车履约衔接
|
||||
version: 1.0.0
|
||||
security:
|
||||
- bearerAuth: []
|
||||
@@ -63,19 +63,3 @@ paths:
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
schemas:
|
||||
Prepare:
|
||||
type: object
|
||||
required: [dispatchNos, idempotencyKey]
|
||||
properties:
|
||||
tripNo: {type: string, nullable: true, maxLength: 64}
|
||||
recordVersion: {type: integer, format: int64, nullable: true, minimum: 0}
|
||||
plan:
|
||||
allOf:
|
||||
- {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'}
|
||||
nullable: true
|
||||
description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。
|
||||
vehicleId: {type: string, nullable: true}
|
||||
driverId: {type: string, nullable: true}
|
||||
dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}}
|
||||
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
|
||||
|
||||
@@ -3,27 +3,16 @@ info:
|
||||
title: 平台运输车次与资源分配
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
车次是一次实际运输的稳定业务编号,客户排车、库存和派送单继续保留各自客户归属。
|
||||
本文件定义计划、资源分配及客户排车单关联命令;分配或关联不会扣库存或标记发车。
|
||||
创建可暂不指定车与司机,幂等键按创建人隔离;相同键相同内容返回同一车次,不同内容拒绝。
|
||||
正式分配要求在役海外车辆及已确认的托位/载重/容积,以及海外司机;司机按预计任务结束时间重新核验且验证证照文件。
|
||||
发车预览与实际发车重新核对司机当前海外区域和身份;分配后档案变更不能沿用原分配时的资格结果。
|
||||
一个车辆或司机同时只能占用一个未释放车次;当前不提供多个未来时段的并行预约。
|
||||
改派先取得新资源,成功后释放旧资源;整次写入与历史处于同一事务。
|
||||
司机预计结束包含承担的返程/转场;修改预计结束须重新核验司机,所有写命令使用recordVersion。
|
||||
普通计划接口不提供修改发车或签收状态的入口;PDA统一发车,结案检查所有客户派送终态及实发数量。
|
||||
货物结案不释放车辆或司机;结束各资源任务独立提交实际结束时间、地点和原因,车辆另确认可用/维修/停用/已归还状态。
|
||||
结案和结束任务命令携带版本及幂等键;结束司机任务不要求其证照仍有效,也不自动恢复其派车资格。
|
||||
关联要求已分配车和司机,客户排车单同起运仓且未开始拣货;可跨客户关联但不改变客户数据归属。
|
||||
同一排车单唯一关联一个车次;关联、改派、解除及尚未执行的派送单车/司机快照同步使用同一事务。
|
||||
关联后子排车不能独立编辑、取消或单独发车;取消车次前须解除关联,已开工不允许直接解除或变更路线。
|
||||
发车前允许在车次统一换车、换司机或调整时间,保留全部拣货事实与客户归属;已确认装载须能容纳于替换车辆。
|
||||
改派或调整计划使原装载确认失效,发车前须重新确认;不支持用计划接口处理在途换车或撤销已执行货物。
|
||||
关联只建立执行单据关系,不代表实际装载量、容量核验或审核已完成。
|
||||
每张客户排车单调度审核通过后允许新拣货;整车确认实际装载并通过理货审核后允许发车。
|
||||
审核仅通过或打回,通过意见选填、打回原因必填;无需额外提交或不同审核人。
|
||||
换车换司机、修改路线或时间使调度与理货审核待审;仅修改备注保留审核及装载。
|
||||
关联变更、重新确认装载或重新审核客户排车单使理货审核待审;保留全部拣货事实及历史审核快照。
|
||||
排车列表是一趟整车作业的唯一主入口,一张排车单对应唯一车次资源记录;同一起运仓允许多个客户和目的地。
|
||||
整车主单与任务不属于单一客户,货物明细、扫码归属、出库和派送单分别保留真实客户边界。
|
||||
本文件保留资源占用查询、历史详情、计划容量、外接预订、结案及车辆/司机任务结束等附加能力。
|
||||
不提供独立派送车次创建、多排车单手工绑定、旧调度/理货审核或装车写入口;审核和装发使用排车主单接口。
|
||||
已绑定排车主单的资源与路线调整由排车主单执行;零真实扫码时显式退回装车前,修改后重新两级审核。
|
||||
车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。
|
||||
调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。
|
||||
发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。
|
||||
货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。
|
||||
版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
@@ -350,7 +339,7 @@ components:
|
||||
dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}}
|
||||
previousDispatches:
|
||||
type: array
|
||||
description: 原绑定历史中已不在当前绑定的批次、客户及来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量
|
||||
description: 原绑定历史中已不在当前绑定的批次及其客户、来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量
|
||||
items: {$ref: '#/components/schemas/PreviousDispatch'}
|
||||
appointmentHistoryUnavailableReason:
|
||||
type: string
|
||||
@@ -656,20 +645,6 @@ components:
|
||||
manifestHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
pallets: {type: array, minItems: 1, maxItems: 1000, items: {$ref: '#/components/schemas/Measurement'}}
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
BindingCommand:
|
||||
type: object
|
||||
required: [recordVersion, dispatchNo, reason]
|
||||
properties:
|
||||
recordVersion: {type: integer, format: int64, minimum: 0}
|
||||
dispatchNo: {type: string, minLength: 1, maxLength: 64}
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
Review:
|
||||
type: object
|
||||
required: [recordVersion, decision]
|
||||
properties:
|
||||
recordVersion: {type: integer, format: int64, minimum: 0}
|
||||
decision: {type: string, enum: [APPROVED, REJECTED]}
|
||||
reason: {type: string, nullable: true, maxLength: 1000, description: 通过选填;打回必填}
|
||||
ReviewState:
|
||||
type: object
|
||||
required: [status]
|
||||
@@ -685,17 +660,15 @@ components:
|
||||
properties:
|
||||
appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}}
|
||||
dispatchNo: {type: string}
|
||||
customerId: {type: string, description: 排车单自身客户归属,由服务端解析}
|
||||
packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数}
|
||||
boundBy: {type: string}
|
||||
boundAt: {type: string, format: date-time}
|
||||
review: {$ref: '#/components/schemas/ReviewState'}
|
||||
PreviousDispatch:
|
||||
type: object
|
||||
required: [dispatchNo, customerId, recordVersion, recordedAt, appointmentBatches]
|
||||
required: [dispatchNo, recordVersion, recordedAt, appointmentBatches]
|
||||
properties:
|
||||
dispatchNo: {type: string}
|
||||
customerId: {type: string}
|
||||
recordVersion: {type: integer, format: int64, description: 原绑定历史的车次版本}
|
||||
recordedAt: {type: string, format: date-time}
|
||||
appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}}
|
||||
@@ -712,16 +685,6 @@ components:
|
||||
name: {type: string, minLength: 1, maxLength: 128}
|
||||
address: {type: string, nullable: true, maxLength: 500}
|
||||
destinationKey: {type: string, nullable: true, maxLength: 160, description: 卸货站匹配Loading货物的destinationKey;未配置时可以保存计划但不能确认装载}
|
||||
Create:
|
||||
type: object
|
||||
required: [warehouseId, plannedDepartureAt, expectedDriverEndAt, stops, idempotencyKey]
|
||||
properties:
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
plannedDepartureAt: {type: string, format: date-time}
|
||||
expectedDriverEndAt: {type: string, format: date-time}
|
||||
stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}}
|
||||
remark: {type: string, nullable: true, maxLength: 1000}
|
||||
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
|
||||
Plan:
|
||||
type: object
|
||||
required: [recordVersion, plannedDepartureAt, expectedDriverEndAt, stops, reason]
|
||||
|
||||
@@ -4038,10 +4038,6 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
vehicleAppointment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchAppointmentReference'
|
||||
nullable: true
|
||||
remark:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -5602,6 +5598,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchBatchAllocation'
|
||||
nullable: true
|
||||
sourceOrigin:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchSourceOrigin'
|
||||
nullable: true
|
||||
required:
|
||||
- dispatchItemId
|
||||
- customerId
|
||||
@@ -5630,6 +5630,7 @@ components:
|
||||
- grossWeightKg
|
||||
- volumeCbm
|
||||
- batchAllocations
|
||||
- sourceOrigin
|
||||
DispatchCommandResult:
|
||||
type: object
|
||||
x-java-record: DispatchContracts.CommandResult
|
||||
@@ -5797,7 +5798,6 @@ components:
|
||||
sourceType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchSourceType'
|
||||
nullable: true
|
||||
planPalletId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
@@ -5815,7 +5815,9 @@ components:
|
||||
nullable: true
|
||||
sourceVersion:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- sourceType
|
||||
- sourceVersion
|
||||
DispatchCreate:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.Create
|
||||
@@ -5857,22 +5859,6 @@ components:
|
||||
- idempotencyKey
|
||||
- warehouseId
|
||||
description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。
|
||||
DispatchAppointmentReference:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.AppointmentReference
|
||||
properties:
|
||||
referenceNo:
|
||||
type: string
|
||||
nullable: true
|
||||
recordVersion:
|
||||
oneOf:
|
||||
- type: string
|
||||
pattern: ^[0-9]+$
|
||||
- type: integer
|
||||
minimum: 0
|
||||
description: 已有单写入必填;0合法。响应Long按十进制字符串输出。
|
||||
required:
|
||||
- recordVersion
|
||||
DispatchAddCargo:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.AddCargo
|
||||
@@ -6351,24 +6337,6 @@ components:
|
||||
- status
|
||||
- confirmedAt
|
||||
- blockReason
|
||||
DispatchVehicleAppointment:
|
||||
type: object
|
||||
x-java-record: DispatchViews.VehicleAppointment
|
||||
properties:
|
||||
referenceNo:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
nullable: true
|
||||
appointmentAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
required:
|
||||
- referenceNo
|
||||
- status
|
||||
- appointmentAt
|
||||
DispatchDispatchInfo:
|
||||
type: object
|
||||
x-java-record: DispatchViews.DispatchInfo
|
||||
@@ -6393,10 +6361,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchFbaAppointment'
|
||||
nullable: true
|
||||
vehicleAppointment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchVehicleAppointment'
|
||||
nullable: true
|
||||
appointmentBatches:
|
||||
type: array
|
||||
items:
|
||||
@@ -6408,7 +6372,6 @@ components:
|
||||
- departedAt
|
||||
- estimatedArrivalAt
|
||||
- fbaAppointments
|
||||
- vehicleAppointment
|
||||
- appointmentBatches
|
||||
DispatchDocuments:
|
||||
type: object
|
||||
@@ -6479,6 +6442,12 @@ components:
|
||||
sourceKey:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceBusinessType:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceBusinessNo:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- customerId
|
||||
- customerName
|
||||
@@ -6490,6 +6459,8 @@ components:
|
||||
- volumeCbm
|
||||
- containerNo
|
||||
- sourceKey
|
||||
- sourceBusinessType
|
||||
- sourceBusinessNo
|
||||
DispatchDetail:
|
||||
type: object
|
||||
x-java-record: DispatchViews.Detail
|
||||
@@ -6660,6 +6631,14 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchReturnCarton'
|
||||
nullable: true
|
||||
sourceKey:
|
||||
type: string
|
||||
sourceBusinessType:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceBusinessNo:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- dispatchItemId
|
||||
- customerId
|
||||
@@ -6670,6 +6649,9 @@ components:
|
||||
- pickedPackages
|
||||
- positions
|
||||
- returnCartons
|
||||
- sourceKey
|
||||
- sourceBusinessType
|
||||
- sourceBusinessNo
|
||||
DispatchPosition:
|
||||
type: object
|
||||
x-java-record: DispatchViews.Position
|
||||
@@ -6970,6 +6952,11 @@ components:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
selectionGroup:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchSelectionGroup'
|
||||
required:
|
||||
- selectionGroup
|
||||
DispatchCandidateList:
|
||||
type: object
|
||||
x-java-record: DispatchPlanningModels.CandidateList
|
||||
@@ -7860,5 +7847,132 @@ components:
|
||||
sizeBytes:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
DispatchSelectionGroup:
|
||||
type: object
|
||||
x-java-record: DispatchPlanningModels.SelectionGroup
|
||||
properties:
|
||||
groupKey:
|
||||
type: string
|
||||
groupVersion:
|
||||
type: string
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchSelectionGroupSource'
|
||||
pallets:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchSelectionGroupPallet'
|
||||
complete:
|
||||
type: boolean
|
||||
blockReasons:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- groupKey
|
||||
- groupVersion
|
||||
- sources
|
||||
- pallets
|
||||
- complete
|
||||
- blockReasons
|
||||
description: 服务端按完整实托及整换标申请计算未被页面筛选截断的来源集合;版本绑定组成、完工轮次、实际位置和各来源版本。complete=false或blockReasons非空禁止选入。
|
||||
DispatchSelectionGroupSource:
|
||||
type: object
|
||||
x-java-record: DispatchPlanningModels.SelectionGroupSource
|
||||
properties:
|
||||
selection:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCargoSelection'
|
||||
sourceKey:
|
||||
type: string
|
||||
customerId:
|
||||
type: string
|
||||
customerName:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceBusinessType:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceBusinessNo:
|
||||
type: string
|
||||
nullable: true
|
||||
packages:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
required:
|
||||
- selection
|
||||
- sourceKey
|
||||
- customerId
|
||||
- customerName
|
||||
- sourceBusinessType
|
||||
- sourceBusinessNo
|
||||
- packages
|
||||
description: 客户和换标业务号按逻辑来源保留;selection携带本来源权威版本,正式建单和加货显式提交完整组,不从展示sourceKey推业务号。
|
||||
DispatchSelectionGroupPallet:
|
||||
type: object
|
||||
x-java-record: DispatchPlanningModels.SelectionGroupPallet
|
||||
properties:
|
||||
warehousePalletId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
palletNo:
|
||||
type: string
|
||||
sourceKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
packageCount:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
required:
|
||||
- warehousePalletId
|
||||
- palletNo
|
||||
- sourceKeys
|
||||
- packageCount
|
||||
description: 每实托只列一次,sourceKeys列出全部逻辑贡献;实际装载按实托测量,不能把整托容量重复计到各来源。
|
||||
DispatchSourceOrigin:
|
||||
type: object
|
||||
x-java-record: DispatchContracts.SourceOrigin
|
||||
properties:
|
||||
dispatchItemSourceId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
relabelApplicationId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
relabelItemId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
sourceInventoryBalanceId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
sourceInboundOrderId:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
sourceInboundOrderNo:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceInboundCode:
|
||||
type: string
|
||||
nullable: true
|
||||
productQuantity:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
nullable: true
|
||||
required:
|
||||
- dispatchItemSourceId
|
||||
- relabelApplicationId
|
||||
- relabelItemId
|
||||
- sourceInventoryBalanceId
|
||||
- sourceInboundOrderId
|
||||
- sourceInboundOrderNo
|
||||
- sourceInboundCode
|
||||
- productQuantity
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
|
||||
+8
-5
@@ -17,17 +17,17 @@ class AppointmentImpactRelationTest {
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR,status VARCHAR,stage VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT UNIQUE,dispatch_no VARCHAR,trip_id BIGINT UNIQUE)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)");
|
||||
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')");
|
||||
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)");
|
||||
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'DS-1','ACTIVE','BEFORE_LOADING'),(2,'DS-WRONG','ACTIVE','BEFORE_LOADING')");
|
||||
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2),(2,'B',1)");
|
||||
s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')");
|
||||
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)");
|
||||
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'DS-1',1),(2,'DS-WRONG',2)");
|
||||
s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
@@ -40,6 +40,9 @@ class AppointmentImpactRelationTest {
|
||||
assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
|
||||
assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1");
|
||||
assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B");
|
||||
assertThat(relations.dispatches("20")).extracting(AppointmentImpactTransportGateway.Dispatch::dispatchNo).containsExactly("DS-1");
|
||||
assertThat(new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("20")).trips())
|
||||
.extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ class Phase4BrowserApiBoundaryTest {
|
||||
.filter(method -> method.isAnnotationPresent(PostMapping.class))
|
||||
.flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value()))
|
||||
.collect(Collectors.toSet());
|
||||
assertEquals(Set.of("/drafts/snapshot", "/pre-arrange", "/drafts/pre-arrange", "/drafts/containers",
|
||||
assertEquals(Set.of("/drafts/snapshot", "/drafts", "/drafts/pre-arrange", "/drafts/containers",
|
||||
"/draft-locks/lock", "/draft-locks/unlock", "/draft-locks/release"), postPaths);
|
||||
assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(DeleteMapping.class)).count() == 1);
|
||||
|
||||
+10
-1
@@ -15,7 +15,16 @@ public final class DeliveryDispatchFixtures {
|
||||
return new CargoLine(id,customer,"客户"+customer,id,"LM-"+id,"SOURCE-"+id,"RECEIVED","LOCAL",null,"IN-"+id,
|
||||
null,pallet,"PALLET-"+pallet,id,id,id,id,null,null,null,null,
|
||||
new Destination("FBA:"+destination,"FBA",null,destination,destination,null,null,null,null),
|
||||
packages,0,null,null,List.of());
|
||||
packages,0,null,null,List.of(),null);
|
||||
}
|
||||
/** 换标沿用真实履约订单及逐次拣货归属,原货源单独进入冻结快照。 */
|
||||
public static CargoLine relabelCargo(long id,String customer,String destination,long pallet,long packages,SourceOrigin origin) {
|
||||
var row=cargo(id,customer,destination,pallet,packages);
|
||||
return new CargoLine(row.dispatchItemId(),row.customerId(),row.customerName(),row.orderId(),row.orderNo(),
|
||||
row.sourceKey(),"RELABEL",row.sourceRegion(),row.sourceInboundId(),row.inboundCode(),row.planPalletId(),
|
||||
row.warehousePalletId(),row.palletNo(),row.pickId(),row.attributionId(),row.inventoryPositionId(),null,null,null,
|
||||
row.cartonMark(),"RL-"+origin.relabelApplicationId(),row.destination(),row.packages(),row.productQuantity(),
|
||||
row.grossWeightKg(),row.volumeCbm(),row.batchAllocations(),origin);
|
||||
}
|
||||
/** 构造同仓完整拣货的整车上下文,发车后只改变业务计数。 */
|
||||
public static Context context(List<CargoLine> cargo,boolean departed) {
|
||||
|
||||
+11
-9
@@ -39,12 +39,13 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
DeliverySourceEvidenceMapper mapper;
|
||||
volatile Runnable beforeFirstSourceRead = () -> {};
|
||||
final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>();
|
||||
/** 原生整车和唯一任务没有客户头,发布证据沿真实明细、扫码与来源行读取。 */
|
||||
Fixture() {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
@@ -131,10 +132,11 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); }
|
||||
void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); }
|
||||
void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); }
|
||||
/** 创建真实客户明细成员及原生departed_at,保留签收与重派的确定时间顺序。 */
|
||||
void shipment(long id,int packages) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-01-01 01:00:00')",id);
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id);
|
||||
}
|
||||
@@ -156,17 +158,17 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1);
|
||||
// Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent.
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1));
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1));
|
||||
f.pick(3,2,101,2,30L);
|
||||
// Keep deterministic chronology without sleeping or depending on wall clock granularity.
|
||||
f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");
|
||||
f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");
|
||||
f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102");
|
||||
}
|
||||
@Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() {
|
||||
var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first");
|
||||
f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1");
|
||||
f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0));
|
||||
f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2");
|
||||
f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-04 00:00:00' WHERE id=2");
|
||||
f.pick(2,2,101,4,30L);f.sign(2,"repeat-four");
|
||||
assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY");
|
||||
}
|
||||
@@ -211,9 +213,9 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
@Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() {
|
||||
var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null);
|
||||
f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty();
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L);
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L);
|
||||
f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty();
|
||||
f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L);
|
||||
f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L);
|
||||
f.sign(3,"third");assertThat(f.published()).containsExactly("S-101");
|
||||
}
|
||||
@Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() {
|
||||
|
||||
+73
-2
@@ -42,7 +42,7 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json);
|
||||
private final Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false);
|
||||
private Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false);
|
||||
private final Actor actor=new Actor("TEST",ActorMode.PLATFORM,null);
|
||||
|
||||
/** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */
|
||||
@@ -79,7 +79,7 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
|
||||
service=(DispatchDeliveryService)proxy.getProxy();
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenReturn(context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(fees.external(any())).thenReturn(Map.of("101",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()),
|
||||
"102",new FleetBookingFreight.Preview("freight",true,List.of(),List.of())));
|
||||
when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenThrow(new IllegalStateException("主单CAS失败"));
|
||||
@@ -102,6 +102,77 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
verify(execution,never()).recordArrangement(any(),any(),any(),any(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 真实 Mapper 保存安排和逐次归属后,出库及派送均可独立定位原货源,不依赖可变履约单回填。 */
|
||||
@Test void persistedAttributionAndDeliveryReferencesResolveImmutableRelabelOrigins() throws Exception {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null);
|
||||
var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L);
|
||||
context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second),
|
||||
relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false);
|
||||
db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(3,'101','INBOUND_COMPLETED',0),(4,'101','INBOUND_COMPLETED',0)");
|
||||
// 成功用例覆盖默认 CAS 失败桩时不能调用旧桩;保留失败用例原有回滚语义。
|
||||
doAnswer(a->{
|
||||
ArrangementFact fact=a.getArgument(3);CommandIdentity identity=a.getArgument(2);
|
||||
return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,identity.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING,
|
||||
DEPARTED,Effect.APPLIED,context.counts(),fact.result(),false,false);
|
||||
}).when(execution).recordArrangement(any(),any(),any(),any(),anyBoolean());
|
||||
var result=service.arrange(actor,"DP-10",request());
|
||||
assertNotNull(result);
|
||||
assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pallet_item",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
|
||||
assertEquals(3,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
|
||||
assertEquals(5,db.queryForObject("SELECT package_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class));
|
||||
assertEquals(1,db.queryForObject("SELECT pallet_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT pallet_count FROM ll_outbound_order WHERE customer_id='101'",Integer.class));
|
||||
|
||||
// 修改履约单展示事实后,仍只通过原安排快照和已保存的真实归属定位来源。
|
||||
db.update("UPDATE ll_order SET order_no=CONCAT('CHANGED-',id)");
|
||||
var outboundRows=db.queryForList("""
|
||||
SELECT i.customer_id,i.source_attribution_id,i.source_order_id,i.dispatch_item_id,i.package_count,
|
||||
i.gross_weight_kg,i.volume_cbm,CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
|
||||
FROM ll_outbound_pallet_item i
|
||||
JOIN ll_outbound_order o ON o.id=i.outbound_order_id AND o.customer_id=i.customer_id
|
||||
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=o.dispatch_order_id
|
||||
ORDER BY i.source_attribution_id
|
||||
""");
|
||||
for (var item:outboundRows) {
|
||||
List<CargoLine> frozen=json.readValue(item.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
long attribution=((Number)item.get("SOURCE_ATTRIBUTION_ID")).longValue();
|
||||
var row=frozen.stream().filter(c->c.customerId().equals(item.get("CUSTOMER_ID"))&&c.attributionId()==attribution)
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(context.cargo().stream().filter(c->c.attributionId()==attribution).findFirst().orElseThrow().sourceOrigin(),row.sourceOrigin());
|
||||
assertEquals(row.orderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue());
|
||||
assertNotEquals(row.sourceOrigin().sourceInboundOrderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue());
|
||||
assertEquals(row.packages(),((Number)item.get("PACKAGE_COUNT")).longValue());
|
||||
assertNull(item.get("GROSS_WEIGHT_KG"));assertNull(item.get("VOLUME_CBM"));
|
||||
}
|
||||
// 费用的 DELIVERY_ORDER.businessId 指向此真实派送行,可按客户及目的地取回其全部冻结来源。
|
||||
var deliveryRows=db.queryForList("""
|
||||
SELECT d.id,d.customer_id,d.delivery_group_key,d.package_count,d.pallet_count,
|
||||
CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
|
||||
FROM ll_delivery_order d
|
||||
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=d.dispatch_order_id
|
||||
ORDER BY d.customer_id,d.delivery_group_key
|
||||
""");
|
||||
int referencedSources=0;
|
||||
for (var delivery:deliveryRows) {
|
||||
List<CargoLine> frozen=json.readValue(delivery.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
var group=frozen.stream().filter(c->c.customerId().equals(delivery.get("CUSTOMER_ID"))
|
||||
&& grouping.documentGroupKey(c.destination()).equals(delivery.get("DELIVERY_GROUP_KEY"))).toList();
|
||||
referencedSources+=group.size();
|
||||
assertFalse(group.isEmpty());assertTrue(group.stream().allMatch(c->c.sourceOrigin()!=null));
|
||||
assertEquals(((Number)delivery.get("PACKAGE_COUNT")).longValue(),group.stream().mapToLong(CargoLine::packages).sum());
|
||||
assertEquals(((Number)delivery.get("PALLET_COUNT")).longValue(),group.stream().map(CargoLine::warehousePalletId).distinct().count());
|
||||
if (group.size()==2) assertEquals(List.of(first,second),group.stream().map(CargoLine::sourceOrigin).toList());
|
||||
}
|
||||
assertEquals(4,referencedSources);
|
||||
List<CargoLine> frozen=json.readValue(db.queryForObject("SELECT cargo_json FROM ll_dispatch_delivery_arrangement",String.class),
|
||||
json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
assertEquals(3,frozen.stream().filter(c->c.sourceOrigin().productQuantity()==null).count());
|
||||
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
|
||||
}
|
||||
|
||||
/** 按真实共享记录构造完整客户目的地请求。 */
|
||||
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
|
||||
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
|
||||
|
||||
+24
-2
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
|
||||
import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.SourceOrigin;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
@@ -80,7 +81,7 @@ class DispatchDeliveryFeesTest {
|
||||
var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3));
|
||||
var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(),
|
||||
raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(),
|
||||
raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations());
|
||||
raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations(),raw.sourceOrigin());
|
||||
var group=grouping.groups(context(List.of(first,second),false)).get(0);
|
||||
when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101))));
|
||||
var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of());
|
||||
@@ -92,6 +93,27 @@ class DispatchDeliveryFeesTest {
|
||||
input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary()));
|
||||
}
|
||||
|
||||
/** 多个换标原货源共组费用只归真实派送,不借第一票原货源或履约单伪造单一归属。 */
|
||||
@Test void relabelFeeRetainsDeliveryReferenceWithoutInventingOneOrigin() {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),
|
||||
relabelCargo(3,"101","ONT8",51,3,second)),false);
|
||||
var group=grouping.groups(context).get(0);
|
||||
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");
|
||||
delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
|
||||
service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
|
||||
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);
|
||||
verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
|
||||
assertEquals("DELIVERY_ORDER",saved.getValue().businessType());
|
||||
assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo());
|
||||
assertEquals("101",saved.getValue().customerId());
|
||||
assertNull(saved.getValue().orderId());assertNull(saved.getValue().inboundId());assertNull(saved.getValue().inboundCode());
|
||||
assertEquals(List.of(first,second),group.cargo().stream().map(CargoLine::sourceOrigin).toList());
|
||||
assertTrue(group.cargo().stream().allMatch(c->c.sourceOrigin().productQuantity()==null&&c.grossWeightKg()==null&&c.volumeCbm()==null));
|
||||
assertEquals(5,group.packages());assertEquals(1,group.palletCount());
|
||||
}
|
||||
|
||||
/** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */
|
||||
@Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception {
|
||||
var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class);
|
||||
@@ -109,7 +131,7 @@ class DispatchDeliveryFeesTest {
|
||||
/** 真实头程关联只在来源行声明,不从展示编号推断。 */
|
||||
private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(),
|
||||
"FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(),
|
||||
c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations()); }
|
||||
c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations(),c.sourceOrigin()); }
|
||||
/** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */
|
||||
private ArrangeGroup input(DeliveryCargoGroups.Group group,List<CoverageSelection> coverage,List<FeeItem> fees) {
|
||||
return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees);
|
||||
|
||||
+37
@@ -72,6 +72,43 @@ class DispatchDeliveryServiceTest {
|
||||
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
|
||||
}
|
||||
|
||||
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
|
||||
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null);
|
||||
var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L);
|
||||
context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second),
|
||||
relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false);
|
||||
var result=(ArrangementResult)service.arrange(actor,"DP-10",request("RELABEL-ARRANGE")).result();
|
||||
assertEquals(2,result.outbounds().size());
|
||||
assertEquals(3,result.deliveries().size());
|
||||
var ont8=result.deliveries().stream().filter(d->d.customerId().equals("101")&&d.destination().warehouseCode().equals("ONT8"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(5,ont8.packageCount());assertEquals(1,ont8.palletCount());
|
||||
assertEquals(2,result.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8"))
|
||||
.map(DeliveryReference::customerId).distinct().count());
|
||||
List<CargoLine> cargo=json.readValue(arrangement.getCargoJson(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
assertEquals(context.cargo(),cargo);
|
||||
var created=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(repository,times(4)).create(created.capture());
|
||||
for (var item:created.getAllValues()) {
|
||||
var source=cargo.stream().filter(c->c.customerId().equals(item.getCustomerId())
|
||||
&& c.attributionId().equals(item.getSourceAttributionId())).findFirst().orElseThrow();
|
||||
assertEquals(source.orderId(),item.getSourceOrderId());
|
||||
assertNotEquals(source.sourceOrigin().sourceInboundOrderId(),item.getSourceOrderId());
|
||||
assertEquals(source.dispatchItemId(),item.getDispatchItemId());
|
||||
assertEquals(source.packages(),item.getPackageCount().longValue());
|
||||
assertNull(item.getGrossWeightKg());assertNull(item.getVolumeCbm());
|
||||
}
|
||||
assertEquals(3,cargo.stream().filter(c->c.sourceOrigin().productQuantity()==null).count());
|
||||
assertTrue(outbounds.stream().allMatch(o->o.getGrossWeightKg()==null&&o.getVolumeCbm()==null));
|
||||
assertTrue(deliveries.stream().allMatch(d->d.getTotalWeightKg()==null&&d.getTotalVolumeCbm()==null));
|
||||
var orders=org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(repository,times(4)).lockSource(anyString(),orders.capture());
|
||||
assertEquals(List.of(1L,2L,3L,4L),orders.getAllValues());
|
||||
}
|
||||
|
||||
/** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */
|
||||
@Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() {
|
||||
var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING,
|
||||
|
||||
+31
-11
@@ -14,13 +14,11 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class DispatchFeeCargoMapperTest {
|
||||
/** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */
|
||||
@Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() {
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
|
||||
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
|
||||
item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A");
|
||||
item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
|
||||
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
|
||||
assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> {
|
||||
var query = nativeGateway(item);
|
||||
assertThat(query.sourceCargo("DP-1", "101")).singleElement().satisfies(source -> {
|
||||
assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
@@ -29,17 +27,28 @@ class DispatchFeeCargoMapperTest {
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"})
|
||||
void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound);
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
|
||||
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
|
||||
item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
|
||||
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
|
||||
var sources = query.sources(dispatch, java.util.List.of(item));
|
||||
var query = nativeGateway(item);
|
||||
var sources = query.sourceCargo("DP-1", "101");
|
||||
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B");
|
||||
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10);
|
||||
item.setPackageCount(19);
|
||||
assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致");
|
||||
assertThatThrownBy(() -> query.sourceCargo("DP-1", "101")).hasMessageContaining("箱数未核平");
|
||||
}
|
||||
/** 使用当前Fleet整车来源入口,隔离无关资源写链,不保留旧客户主单查询类。 */
|
||||
private com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter nativeGateway(com.baiyue.lastleg.dispatch.entity.DispatchItem item) {
|
||||
var aggregates = org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class);
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchAggregate();
|
||||
dispatch.setId(1L); dispatch.setDispatchNo("DP-1");
|
||||
item.setCustomerId("101"); item.setDispatchOrderId(1L);
|
||||
org.mockito.Mockito.when(aggregates.find("DP-1")).thenReturn(dispatch);
|
||||
org.mockito.Mockito.when(aggregates.cargo(1L)).thenReturn(java.util.List.of(item));
|
||||
return new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(aggregates,null,null,null,
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper),null,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
}
|
||||
|
||||
JdbcTemplate jdbc; DispatchFeeCargoMapper mapper;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
@@ -48,7 +57,7 @@ class DispatchFeeCargoMapperTest {
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)");
|
||||
@@ -56,7 +65,7 @@ class DispatchFeeCargoMapperTest {
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,1),(602,2)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
@@ -69,6 +78,17 @@ class DispatchFeeCargoMapperTest {
|
||||
assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L);
|
||||
assertThat(mapper.list("202", 1)).isEmpty();
|
||||
}
|
||||
/** 同一整车与任务内按真实明细和拣货客户分别统计,不借其他客户货量。 */
|
||||
@Test void sharedDispatchAndTaskKeepEachCustomersSourcesSeparate() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(202,1,'202',501,'PLT-501','WH-1',NULL,'IN-OTHER')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'202',202,103,7)");
|
||||
assertThat(mapper.list("101",1)).extracting(row -> row.packages()).containsExactly(10L,10L);
|
||||
assertThat(mapper.list("202",1)).singleElement().satisfies(row -> {
|
||||
assertThat(row.inboundCode()).isEqualTo("IN-OTHER");
|
||||
assertThat(row.packages()).isEqualTo(57L);
|
||||
});
|
||||
assertThat(mapper.list("303",1)).isEmpty();
|
||||
}
|
||||
@Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)");
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchAggregateController;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -124,7 +124,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "vehicleAppointment", "remark"),
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark"),
|
||||
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
|
||||
@@ -184,8 +184,8 @@ class PlatformDispatchOutboundContractTest {
|
||||
|
||||
@Test
|
||||
void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception {
|
||||
String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class);
|
||||
String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class);
|
||||
String dispatchFind = select(DispatchAggregateMapper.class, "find", String.class);
|
||||
String dispatchLock = select(DispatchAggregateMapper.class, "lock", String.class);
|
||||
String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class);
|
||||
String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class);
|
||||
|
||||
|
||||
+5
-4
@@ -18,13 +18,14 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** Two real database transactions execute the production mapper SQL, with bounded waits. */
|
||||
class SourceDeliveryCompletionConcurrencyTest {
|
||||
/** 两个真实签收事务共享来源锁,最后持锁者必须读到前一签收提交。 */
|
||||
@Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa","");
|
||||
var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
@@ -33,9 +34,9 @@ class SourceDeliveryCompletionConcurrencyTest {
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)");
|
||||
for (int i=1;i<=2;i++) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-10-03 01:00:00')",i);
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i);
|
||||
|
||||
+25
-22
@@ -15,9 +15,9 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
@@ -99,7 +99,7 @@ class DeliveryTransferServiceTest {
|
||||
.valueToTree(detail).path("task").path("outboundType").asText());
|
||||
}
|
||||
|
||||
@Mock DispatchOrderMapper dispatchOrders;
|
||||
@Mock DispatchAggregateRepository dispatches;
|
||||
@Mock DispatchItemMapper dispatchItems;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock OutboundPalletItemMapper outboundPallets;
|
||||
@@ -128,21 +128,19 @@ class DeliveryTransferServiceTest {
|
||||
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
|
||||
|
||||
private DeliveryTransferService service;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
@Mock com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "execution", execution);
|
||||
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
outboundOrders,outboundPallets,deliveryOrders,pdaTasks,deliveryOperations,
|
||||
mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class),returnCartons,
|
||||
mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),tailOrders,histories));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
|
||||
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
||||
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
|
||||
@@ -665,7 +663,11 @@ class DeliveryTransferServiceTest {
|
||||
.getMethod("deliveryPickedSkus", String.class, Long.class);
|
||||
String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
|
||||
assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}"));
|
||||
assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}"));
|
||||
assertTrue(sql.contains("BINARY p.customer_id=BINARY #{customerId}"));
|
||||
assertTrue(sql.contains("di.dispatch_order_id=t.dispatch_order_id"));
|
||||
assertTrue(sql.contains("BINARY di.customer_id=BINARY p.customer_id"));
|
||||
assertTrue(sql.contains("BINARY a.customer_id=BINARY p.customer_id"));
|
||||
assertTrue(!sql.contains("t.customer_id"));
|
||||
assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END"));
|
||||
assertTrue(sql.contains("COALESCE(a.packages,p.packages)"));
|
||||
assertTrue(!sql.contains("ll_order_detail"));
|
||||
@@ -748,9 +750,9 @@ class DeliveryTransferServiceTest {
|
||||
.map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining()));
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any());
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any());
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution, fleetBindings);
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution);
|
||||
ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(execution).lockForArrangement(any(),eq("DSP-101"));
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L);
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301");
|
||||
service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试"));
|
||||
verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any());
|
||||
}
|
||||
@@ -966,10 +968,10 @@ class DeliveryTransferServiceTest {
|
||||
OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
DispatchAggregate dispatch = new DispatchAggregate();
|
||||
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch);
|
||||
when(dispatches.find("DSP-401")).thenReturn(dispatch);
|
||||
|
||||
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
|
||||
|
||||
@@ -978,6 +980,18 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
}
|
||||
|
||||
/** 直接扫排车号使用无客户头整车,并保持PDA绑定仓隔离。 */
|
||||
@Test
|
||||
void directDispatchNumberResolvesOnlyInItsWarehouse() {
|
||||
DispatchAggregate dispatch = new DispatchAggregate();
|
||||
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
|
||||
when(dispatches.find("DSP-401")).thenReturn(dispatch);
|
||||
assertEquals("DSP-401", service.resolvePdaTask("WH-1", "DSP-401").dispatchNo());
|
||||
ResponseStatusException denied = assertThrows(ResponseStatusException.class,
|
||||
() -> service.resolvePdaTask("WH-2", "DSP-401"));
|
||||
assertEquals(404, denied.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundNumberResolvesOneClickTaskInBoundWarehouse() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
@@ -1264,17 +1278,6 @@ class DeliveryTransferServiceTest {
|
||||
|
||||
|
||||
|
||||
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART");
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
|
||||
String destinationName, String inboundCode, int packages) {
|
||||
DispatchItem value = new DispatchItem();
|
||||
|
||||
+232
-32
@@ -8,6 +8,31 @@ import com.baiyue.lastleg.returnreceipt.mapper.*;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchExecutionPort;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -104,40 +129,215 @@ class ReturnOutboundClosureTest {
|
||||
verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() {
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
|
||||
outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
|
||||
application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1");
|
||||
application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application);
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK");
|
||||
item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
|
||||
carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L);
|
||||
service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"));
|
||||
assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING");
|
||||
assertThat(outbound.getStatus()).isEqualTo("CANCELLED");
|
||||
verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"),
|
||||
argThat(request -> "CANCELLED".equals(request.status())));
|
||||
var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService);
|
||||
sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1");
|
||||
sequence.verify(outbounds).lockGlobalByNo("OUT1");
|
||||
sequence.verify(cartons).lockMark("RC#1-1");
|
||||
sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
/** 实际取消只撤销安排,已确认申请、换标照片/逐件事实及原库存保持不变。 */
|
||||
@Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() throws Exception {
|
||||
var f = new CancellationFixture(); var proof = f.proof();
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING");
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pda_task", "status", 20)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_delivery_order", "status", 60)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND");
|
||||
assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "current_status", 70)).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
assertThat(f.locks.subList(0,3)).containsExactly("relabel", "outbound", "carton");
|
||||
verifyNoInteractions(f.relabelLifecycle, f.execution);
|
||||
var after = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(after);
|
||||
assertThatThrownBy(() -> f.service.pdaPick("1", "picker", "TASK1",
|
||||
new DeliveryRequests.PdaPick(40L,null,1L,"old-outbound-pick",List.of("RC#1-1"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("not available for picking");
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
}
|
||||
|
||||
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() {
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
|
||||
outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1);
|
||||
when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
|
||||
assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")))
|
||||
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() throws Exception {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_outbound_pallet_item SET picked_package_count=1 WHERE id=40");
|
||||
f.insert("ll_outbound_pda_pick", "id", 90, "customer_id", "C1", "task_id", 20,
|
||||
"outbound_pallet_item_id", 40, "packages", 1, "idempotency_key", "actual-pick");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel)
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started");
|
||||
verifyNoInteractions(relabelService);
|
||||
verify(cartons, never()).updateById(any(ReturnInventoryCarton.class));
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Long.class)).isEqualTo(1);
|
||||
verifyNoInteractions(f.relabelLifecycle, f.execution, f.fees);
|
||||
}
|
||||
|
||||
/** 普通仓托换标同样只取消出库安排,不释放真实原入仓位置的HOLD或重做换标。 */
|
||||
@Test void cancelsUntouchedWarehousePalletRelabelWithoutReleasingItsHold() throws Exception {
|
||||
var f = new CancellationFixture(); f.warehousePalletSource(); var proof = f.proof();
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION");
|
||||
assertThat(f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)).isEqualTo(1);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull();
|
||||
verifyNoInteractions(f.relabelLifecycle,f.execution);
|
||||
var cancelled = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(cancelled);
|
||||
}
|
||||
|
||||
/** 原出库号不匹配不能通过申请ID或其他客户的同类记录恢复关联。 */
|
||||
@Test void cancellationRejectsChangedRelabelAssociationWithoutAnyWrite() throws Exception {
|
||||
for (String column : List.of("outbound_order_no", "customer_id", "overseas_warehouse_id")) {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_relabel_application SET " + column + "=? WHERE id=50", "other");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("关联状态已变化");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
}
|
||||
}
|
||||
|
||||
/** 相同箱号也必须属于原出库的客户和仓,不能解除另一个客户的库存预留。 */
|
||||
@Test void cancellationRejectsForeignCustomerOrWarehouseCarton() throws Exception {
|
||||
for (String column : List.of("customer_id", "overseas_warehouse_id")) {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=10", "other");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("预留状态异常");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
}
|
||||
}
|
||||
|
||||
/** 在申请和箱预留已实际恢复后注入下游失败,所有关联、任务及派送状态必须整体回滚。 */
|
||||
@Test void laterCancellationFailureRollsBackTheWholeArrangementAndAllowsRetry() throws Exception {
|
||||
var f = new CancellationFixture(); var original = f.state();
|
||||
when(f.fees.listForBusiness("C1", "DELIVERY_ORDER", 60L)).thenAnswer(call -> {
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING");
|
||||
throw new IllegalStateException("取消后续核对失败");
|
||||
});
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(IllegalStateException.class).hasMessage("取消后续核对失败");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
doReturn(List.of()).when(f.fees).listForBusiness("C1", "DELIVERY_ORDER", 60L);
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
}
|
||||
|
||||
/** 真实数据库装配取消仓储与全部Mapper;仅隔离费用查询等外部端口,不伪造取消成功。 */
|
||||
private static final class CancellationFixture {
|
||||
final JdbcTemplate db;
|
||||
final DeliveryTransferService service;
|
||||
final OverseasFeeSourceService fees = mock(OverseasFeeSourceService.class);
|
||||
final DispatchExecutionPort execution = mock(DispatchExecutionPort.class);
|
||||
final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelLifecycle = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class);
|
||||
final List<String> locks = new ArrayList<>();
|
||||
|
||||
/** 按统一初始化实际建表,保留列、默认值及约束,不以Java字段猜测正式结构。 */
|
||||
CancellationFixture() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:cancel-native-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(ds);
|
||||
String schema = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for (String table : List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_pda_pick",
|
||||
"ll_delivery_order", "ll_delivery_operation", "ll_relabel_application", "ll_return_inventory_carton",
|
||||
"ll_return_inventory_carton_item", "ll_warehouse_transfer_order", "ll_order", "ll_order_status_history",
|
||||
"ll_relabel_item", "ll_relabel_pda_task", "ll_relabel_pda_unit_fact", "ll_relabel_pda_history",
|
||||
"ll_inventory_balance", "ll_inventory_position", "ll_inventory_ledger")) {
|
||||
int start = schema.indexOf("CREATE TABLE " + table + " ("); int end = schema.indexOf(") ENGINE=", start);
|
||||
if (start < 0 || end < 0) throw new IllegalStateException("统一初始化缺少取消测试表:" + table);
|
||||
db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b", "CLOB")
|
||||
.replaceAll("(?i)\\s+CHARACTER SET\\s+\\w+", "").replaceAll("(?i)\\s+COLLATE\\s+\\w+", "")
|
||||
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)", "$1 " + table + "_$2"));
|
||||
}
|
||||
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
for (var type : List.of(OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class,
|
||||
DeliveryOrderMapper.class,DeliveryOperationMapper.class,RelabelApplicationMapper.class,ReturnInventoryCartonMapper.class,
|
||||
WarehouseTransferOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class)) config.addMapper(type);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
|
||||
var outboundMapper = traced(sessions.getMapper(OutboundOrderMapper.class), OutboundOrderMapper.class, "lockGlobalByNo", "outbound");
|
||||
var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel");
|
||||
var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton");
|
||||
var target = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class),
|
||||
sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper,
|
||||
cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
ReflectionTestUtils.setField(target,"outboundOrders",outboundMapper); ReflectionTestUtils.setField(target,"outboundPallets",sessions.getMapper(OutboundPalletItemMapper.class));
|
||||
ReflectionTestUtils.setField(target,"pdaTasks",sessions.getMapper(OutboundPdaTaskMapper.class)); ReflectionTestUtils.setField(target,"pdaPicks",sessions.getMapper(OutboundPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(target,"feeSources",fees); ReflectionTestUtils.setField(target,"execution",execution);
|
||||
ReflectionTestUtils.setField(target,"relabelService",relabelLifecycle);
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
|
||||
service = (DeliveryTransferService)proxy.getProxy();
|
||||
when(fees.listForBusiness(anyString(),anyString(),anyLong())).thenReturn(List.of());
|
||||
seed();
|
||||
}
|
||||
|
||||
/** 记录真实Mapper的取锁次序,调用及SQL均委托原MyBatis实现。 */
|
||||
private <T> T traced(T actual,Class<T> type,String method,String label) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class<?>[]{type},(proxy,called,args) -> {
|
||||
if (called.getName().equals(method)) locks.add(label);
|
||||
try { return called.invoke(actual,args); }
|
||||
catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); }
|
||||
}));
|
||||
}
|
||||
|
||||
/** 已确认原生换标、未扫码独立出库及其真实派送/来源关联。 */
|
||||
private void seed() {
|
||||
var at = LocalDateTime.of(2026,10,19,12,0);
|
||||
insert("ll_outbound_order","id",30,"customer_id","C1","outbound_no","OUT1","overseas_warehouse_id","1","outbound_type","RELABEL_OUTBOUND","status","WAIT_PICK","idempotency_key","OUT1-CREATE");
|
||||
insert("ll_outbound_pallet_item","id",40,"customer_id","C1","outbound_order_id",30,"carton_marks","RC#1-1","package_count",1,"status","WAIT_PICK");
|
||||
insert("ll_outbound_pda_task","id",20,"customer_id","C1","outbound_order_id",30,"task_no","TASK1","status","PENDING");
|
||||
insert("ll_delivery_order","id",60,"customer_id","C1","delivery_no","DEL1","outbound_order_id",30,"outbound_no","OUT1","delivery_group_key","NATIVE","status","WAIT_DELIVERY","idempotency_key","DEL1-CREATE");
|
||||
insert("ll_relabel_application","id",50,"customer_id","C1","relabel_no","REL1","lastleg_order_id",70,"customer_order_no","CUSTOMER-1","overseas_warehouse_id","1","label_deadline",at.plusDays(1),"contact_name","测试","contact_phone","10000000000","delivery_method","EXPRESS","outbound_order_no","OUT1","status","READY_FOR_OUTBOUND","fulfillment_status","OUTBOUND_CREATED","customer_confirmed_at",at,"customer_confirmation_key","CONFIRMED-1","wallet_status","SETTLED","idempotency_key","REL1-CREATE","request_hash","a".repeat(64));
|
||||
insert("ll_return_inventory_carton","id",10,"customer_id","C1","overseas_warehouse_id","1","carton_mark","RC#1-1","status","OUTBOUND_PENDING","relabel_application_id",50,"relabel_no","REL1","relabel_status","COMPLETED","relabel_completed_at",at,"sku_types",1,"total_quantity",6,"created_at",at,"updated_at",at);
|
||||
insert("ll_return_inventory_carton_item","id",11,"customer_id","C1","carton_id",10,"sku_code","NEW-SKU","quantity",6);
|
||||
insert("ll_order","id",70,"customer_id","C1","order_no","RELABEL-ORDER","order_type","RELABEL_APPLICATION","source_region","OVERSEAS","source_inbound_id","REL1","source_event_id","REL1","source_loading_plan_id","REL1","source_payload_hash","a".repeat(64),"relabel_order_no","REL1","relabel_status","COMPLETED","current_status","WAIT_OUTBOUND","outbound_order_no","OUT1","delivery_order_no","DEL1");
|
||||
insert("ll_relabel_item","id",71,"application_id",50,"customer_id","C1","product_name_zh","测试","total_package_count",1,"notified_package_count",1,"return_carton_id",10,"operation_type","CARTON_LABEL","carton_mark","NEW-BOX");
|
||||
insert("ll_relabel_pda_task","id",80,"application_id",50,"customer_id","C1","relabel_no","REL1","overseas_warehouse_id",1,"status","COMPLETED","operator_key","picker","operator_name","测试","expected_packages",1,"relabelled_packages",1,"claimed_at",at,"completion_photo_file_ids","[501]","reviewed_at",at);
|
||||
insert("ll_relabel_pda_unit_fact","id",81,"task_id",80,"application_id",50,"item_id",71,"customer_id","C1","old_label","RC#1-1","new_label","NEW-BOX","result","RELABELLED","quantity",1,"operator_key","picker","operator_name","测试","occurred_at",at);
|
||||
insert("ll_relabel_pda_history","id",82,"task_id",80,"application_id",50,"customer_id","C1","action","COMPLETE","to_status","WAIT_REVIEW","operator_key","picker","operator_name","测试","detail_json","{\"roundKey\":\"INITIAL\",\"photoFileIds\":[501]}");
|
||||
}
|
||||
|
||||
/** 使用真实原入仓余额和仓位表示普通已换标仓托;原生退货箱记录不参与此场景。 */
|
||||
void warehousePalletSource() {
|
||||
db.update("DELETE FROM ll_return_inventory_carton_item"); db.update("DELETE FROM ll_return_inventory_carton");
|
||||
db.update("UPDATE ll_outbound_pallet_item SET warehouse_pallet_id=501,pallet_no='PL-501',carton_marks='BOX-ORD' WHERE id=40");
|
||||
db.update("UPDATE ll_relabel_item SET return_carton_id=NULL,source_inventory_position_id=101,source_inventory_balance_id=601,source_inbound_order_id=701,source_inbound_order_no='IN-701',source_inbound_code='IN-701' WHERE id=71");
|
||||
db.update("UPDATE ll_relabel_pda_unit_fact SET old_label='BOX-ORD' WHERE id=81");
|
||||
insert("ll_order","id",701,"customer_id","C1","order_no","ORIGINAL-INBOUND","source_region","OVERSEAS","source_inbound_id","IN-701","source_event_id","IN-701","source_loading_plan_id","IN-701","source_payload_hash","b".repeat(64),"current_status","INBOUND_COMPLETED");
|
||||
insert("ll_inventory_balance","id",601,"customer_id","C1","customer_name","测试客户","overseas_warehouse_id","1","inbound_order_id",701,"inbound_code","IN-701","hold_packages",1);
|
||||
insert("ll_inventory_position","id",101,"customer_id","C1","balance_id",601,"overseas_warehouse_id","1","pallet_id",501,"pallet_no","PL-501","warehouse_location_code","LOC-1","hold_packages",1);
|
||||
}
|
||||
|
||||
/** 每次取消经同一个实际Spring事务入口。 */
|
||||
void cancel() { service.cancelOutbound("OUT1","OP1",new DeliveryRequests.Cancel("cancel")); }
|
||||
/** 合成数据只用于测试准备,生产取消全部通过真实仓储和Mapper。 */
|
||||
void insert(String table,Object... fields) {
|
||||
var columns = new ArrayList<String>(); var values = new ArrayList<Object>();
|
||||
for(int i=0;i<fields.length;i+=2){columns.add((String)fields[i]);values.add(fields[i+1]);}
|
||||
db.update("INSERT INTO " + table + "(" + String.join(",",columns) + ") VALUES(" + String.join(",",Collections.nCopies(values.size(),"?")) + ")",values.toArray());
|
||||
}
|
||||
/** 从数据库核对已提交状态,不读取服务内预读对象。 */
|
||||
String text(String table,String column,long id) { return db.queryForObject("SELECT " + column + " FROM " + table + " WHERE id=?",String.class,id); }
|
||||
/** 换标成果和箱内SKU数量不应因取消出库而变化。 */
|
||||
List<List<Map<String,Object>>> proof() {
|
||||
return List.of(db.queryForList("SELECT status,delivery_method,customer_confirmed_at,customer_confirmation_key,wallet_status FROM ll_relabel_application"),
|
||||
db.queryForList("SELECT relabel_application_id,relabel_no,relabel_status,relabel_completed_at,total_quantity,sku_types FROM ll_return_inventory_carton"),
|
||||
db.queryForList("SELECT * FROM ll_return_inventory_carton_item"),
|
||||
db.queryForList("SELECT status,reviewed_at,CAST(completion_photo_file_ids AS VARCHAR) photos FROM ll_relabel_pda_task"),
|
||||
db.queryForList("SELECT * FROM ll_relabel_pda_unit_fact"),
|
||||
db.queryForList("SELECT id,action,CAST(detail_json AS VARCHAR) detail FROM ll_relabel_pda_history"),
|
||||
db.queryForList("SELECT * FROM ll_inventory_balance"),db.queryForList("SELECT * FROM ll_inventory_position"),
|
||||
db.queryForList("SELECT * FROM ll_inventory_ledger"));
|
||||
}
|
||||
/** 全部可变安排记录在失败和重放时必须与原提交状态一致。 */
|
||||
List<List<Map<String,Object>>> state() {
|
||||
return List.of(db.queryForList("SELECT status,outbound_order_no,fulfillment_status,updated_at FROM ll_relabel_application"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_return_inventory_carton"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_outbound_order"),db.queryForList("SELECT status FROM ll_outbound_pallet_item"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_outbound_pda_task"),db.queryForList("SELECT status,version,updated_at FROM ll_delivery_order"),
|
||||
db.queryForList("SELECT current_status,outbound_order_no,delivery_order_no,version FROM ll_order"),db.queryForList("SELECT * FROM ll_delivery_operation"),
|
||||
List.of(Map.of("proof",proof())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -16,6 +16,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
|
||||
@Test
|
||||
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
|
||||
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
|
||||
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
|
||||
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
|
||||
int end = sql.indexOf(" AS overseasWarehouseId", start);
|
||||
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
|
||||
String expression = sql.substring(start, end).replace(">", ">").replace("<", "<");
|
||||
assertTrue(expression.endsWith("END AS CHAR(128))"));
|
||||
assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"));
|
||||
String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1)
|
||||
.replace("#{warehouseId}", "?").replace("</script>", "");
|
||||
String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression
|
||||
+ " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)"
|
||||
+ warehouseFilter;
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
|
||||
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
|
||||
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
|
||||
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
|
||||
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
|
||||
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
|
||||
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
|
||||
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
|
||||
statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002'),"
|
||||
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
|
||||
try (var prepared = connection.prepareStatement(query)) {
|
||||
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
|
||||
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
|
||||
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
|
||||
assertWarehouseRows(prepared, invalid, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */
|
||||
private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List<Long> expected) throws Exception {
|
||||
prepared.setString(1, warehouse);
|
||||
var actual = new ArrayList<Long>();
|
||||
try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); }
|
||||
actual.sort(Long::compareTo);
|
||||
assertEquals(expected, actual, warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
|
||||
for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
|
||||
@@ -200,7 +251,9 @@ class DispatchCandidateMapperContractTest {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("pos.overseas_warehouse_id"));
|
||||
assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains(method.getName().equals("available")
|
||||
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
|
||||
: "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains("source_order.id=ip.source_order_id"));
|
||||
assertTrue(sql.contains("source_order.customer_id=ip.customer_id"));
|
||||
assertTrue(sql.contains("source_order.overseas_warehouse_id"));
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ class DispatchPhysicalWeightTest {
|
||||
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)");
|
||||
int tested = 0;
|
||||
var tested = new java.util.HashSet<String>();
|
||||
for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) {
|
||||
var annotation = method.getAnnotation(Select.class);
|
||||
if (annotation == null) continue;
|
||||
@@ -43,8 +43,8 @@ class DispatchPhysicalWeightTest {
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40");
|
||||
jdbc.update("DELETE FROM ll_order_detail");
|
||||
tested++;
|
||||
tested.add(method.getName());
|
||||
}
|
||||
assertThat(tested).isEqualTo(2);
|
||||
assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-22
@@ -1,32 +1,39 @@
|
||||
package com.baiyue.lastleg.dispatch.fleet;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.service.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetDispatchPlanningAdapterTest {
|
||||
private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
|
||||
private final DispatchService service=mock(DispatchService.class);
|
||||
private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
|
||||
private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway);
|
||||
final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
|
||||
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
|
||||
final DispatchService rules=mock(DispatchService.class);
|
||||
final DispatchAggregateService main=mock(DispatchAggregateService.class);
|
||||
final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
|
||||
final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,sources,rules,gateway,main);
|
||||
@AfterEach void clear(){SecurityContextHolder.clearContext();}
|
||||
private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);}
|
||||
@Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() {
|
||||
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2"));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车");
|
||||
verifyNoInteractions(service);
|
||||
@Test void keepsOtherCustomerAndOtherInboundCargoWhileVerifyingTheExactReceiptSubset(){
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",50));
|
||||
when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"),new FleetDispatchGateway.SourceCargo("OTHER-IN","2","P-2",7,"FBA:ONT8")));
|
||||
adapter.validateIncomingDispatch("DS-1","9",receipt());verify(gateway).sourceCargo("DS-1","9");verifyNoInteractions(main);
|
||||
when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("未完整覆盖");
|
||||
}
|
||||
@Test void reusesOnlyAnExactExistingShipment() {
|
||||
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1"));
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10));
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
|
||||
assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service);
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收");
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源");
|
||||
@Test void joinsTheTripExistingMainAndDoesNotCreateASecondCustomerDispatch(){
|
||||
var user=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of());SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of()));
|
||||
when(mapper.dispatchForTrip("TR-1")).thenReturn("DS-1");var context=mock(DispatchContracts.Context.class);when(context.dispatchId()).thenReturn(1L);when(main.lockForArrangement(any(),eq("DS-1"))).thenReturn(context);
|
||||
when(mapper.selectedPlans(1,"9")).thenReturn(List.of(10L));when(mapper.receivedPlanPallets("9","IN-1")).thenReturn(List.of(10L));
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",10));when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
|
||||
assertThat(adapter.resolveIncoming("TR-1","9",receipt(),"1","booking-original")).isEqualTo("DS-1");verify(main,never()).create(any(),any());verify(main,never()).addCargo(any(),any(),any());verifyNoInteractions(sources,rules);
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("TR-1","9",receipt(),"forged","booking-original")).hasMessageContaining("操作者");
|
||||
}
|
||||
@Test void wrongWarehouseCannotBeAcceptedByMatchingReceiptTotals(){when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","OTHER",10));assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("起运仓");verify(gateway,never()).sourceCargo(any(),any());}
|
||||
}
|
||||
|
||||
+1
-4
@@ -16,7 +16,7 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetPlanningReadPersistenceTest {
|
||||
@Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception {
|
||||
@Test void externalSourcesKeepActualCustomerAndUnknownQuantities() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))");
|
||||
@@ -38,9 +38,6 @@ class FleetPlanningReadPersistenceTest {
|
||||
var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
|
||||
var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class);
|
||||
var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null);
|
||||
assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1);
|
||||
assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1");
|
||||
var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper());
|
||||
var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6")));
|
||||
assertThat(sources).hasSize(2);
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 实际Mapper查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */
|
||||
class DispatchSourceMapperWarehouseBindingTest {
|
||||
private static final long WAREHOUSE = 193000000000200003L;
|
||||
private static final long PHYSICAL_PALLET = 2112349761578209281L;
|
||||
private static final long PLAN = 2112349764502786049L;
|
||||
private static final long PLAN_PALLET = 2112349764502786050L;
|
||||
private static final String CUSTOMER = "2112349740878327809";
|
||||
private static final String SECOND_CUSTOMER = "2112349740878327810";
|
||||
private JdbcTemplate db;
|
||||
private DispatchSourceMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(data);
|
||||
// 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)");
|
||||
plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(DispatchSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestedWarehouseMustMatchTheRealStockPosition() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() {
|
||||
db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() {
|
||||
db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() {
|
||||
db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() {
|
||||
plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
// 无本仓库存的第三份计划不能混入当前组成。
|
||||
plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER),
|
||||
new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"})
|
||||
void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) {
|
||||
db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse);
|
||||
// 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。
|
||||
assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer,
|
||||
long warehouse, long physicalPallet) {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet);
|
||||
}
|
||||
|
||||
private void position(long positionId, String customer, String warehouse, long pallet) {
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet);
|
||||
}
|
||||
}
|
||||
+32
-372
@@ -1,380 +1,40 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class DispatchPdaWorkflowServiceTest {
|
||||
@Mock DispatchOrderMapper orders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock OutboundPalletItemMapper outboundPalletItems;
|
||||
@Mock DispatchItemMapper items;
|
||||
@Mock DispatchPdaTaskMapper tasks;
|
||||
@Mock DispatchPdaPickMapper picks;
|
||||
@Mock DispatchPdaOperationMapper operations;
|
||||
@Mock DispatchPalletLockMapper palletLocks;
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock DispatchPickOrderAttributionService attribution;
|
||||
@Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
private DispatchPdaWorkflowService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "picks", picks);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "palletLocks", palletLocks);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "pickAttribution", attribution);
|
||||
ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
final DispatchPdaWorkflowService service=new DispatchPdaWorkflowService();
|
||||
final DispatchPickingRepository picks=mock(DispatchPickingRepository.class);
|
||||
final DispatchSourceDepartureRepository source=mock(DispatchSourceDepartureRepository.class);
|
||||
final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final FleetPoolOrderBridge fleet=mock(FleetPoolOrderBridge.class);
|
||||
final RelabelApplicationService relabels=mock(RelabelApplicationService.class);
|
||||
final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
|
||||
@BeforeEach void setup(){ReflectionTestUtils.setField(service,"wholePicks",picks);ReflectionTestUtils.setField(service,"wholeDeparture",source);ReflectionTestUtils.setField(service,"pickAttribution",attribution);ReflectionTestUtils.setField(service,"fleetPool",fleet);ReflectionTestUtils.setField(service,"relabelApplications",relabels);}
|
||||
@Test void departureAdvancesEachRealCustomerAndUsesItsOwnImmutablePicks(){
|
||||
var context=context(List.of(cargo("102",22L,5,null),cargo("101",11L,3,null)));
|
||||
var a=List.of(new DispatchPdaPick());var b=List.of(new DispatchPdaPick());when(picks.picks("101",1)).thenReturn(a);when(picks.picks("102",1)).thenReturn(b);
|
||||
var at=Instant.parse("2026-10-19T10:00:00Z");service.departWholeSources(context,actor,result("101","102"),at);
|
||||
var local=LocalDateTime.ofInstant(at,ZoneOffset.UTC);verify(attribution).depart("101","OUT-101","DP-ONE",a,local);verify(attribution).depart("102","OUT-102","DP-ONE",b,local);
|
||||
verify(source).transferOrder("101",11L,local);verify(source).transferOrder("102",22L,local);
|
||||
verify(fleet).departed("101","DP-ONE",Map.of(11L,3),"operator",local);verify(fleet).departed("102","DP-ONE",Map.of(22L,5),"operator",local);
|
||||
verifyNoInteractions(relabels);
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
|
||||
assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT"));
|
||||
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8"));
|
||||
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT"));
|
||||
@Test void missingActualPicksCannotAdvanceAnySourceOrFleetOrder(){
|
||||
assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("101"),Instant.now())).hasMessageContaining("真实扫码");verifyNoInteractions(source,attribution,fleet,relabels);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING");
|
||||
when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of());
|
||||
|
||||
List<DispatchPdaResponses.Task> result = service.list("WH-1", null);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("DSP-1", result.get(0).dispatchNo());
|
||||
verify(tasks).listByWarehouse("WH-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() {
|
||||
when(orders.findGlobalByDispatchNo("DSP-1"))
|
||||
.thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE"));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.task("WH-1", "DSP-1"));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(tasks, never()).findByDispatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskExposesTransferDeliveryGateBeforeDeparture() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
item.setInboundCode("IN-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
|
||||
DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1");
|
||||
|
||||
assertFalse(result.canDepart());
|
||||
assertEquals("请先在平台完成“转派送”,生成待出库单后才能发车。", result.departBlockReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskExposesDepartureWhenPendingOutboundExists() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1");
|
||||
|
||||
assertTrue(result.canDepart());
|
||||
assertNull(result.departBlockReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPickRetryReturnsPersistedResultAfterTaskAutomaticallyCompleted() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
task.setOperatorId("7");
|
||||
DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null);
|
||||
DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false);
|
||||
DispatchPdaOperation replay = operation("PICK", request, persisted);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(operations.findKey("100", "pick-1")).thenReturn(replay);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
|
||||
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
|
||||
|
||||
assertEquals(2, result.packages());
|
||||
verify(fleetBindings, never()).requirePickingApproved(anyLong());
|
||||
verify(inventory, never()).allocate(any(), any(), any());
|
||||
verify(picks, never()).insert((DispatchPdaPick) any());
|
||||
verify(items, never()).bindReceivedPallets(any(), any());
|
||||
var locks = org.mockito.Mockito.inOrder(orders, tasks);
|
||||
locks.verify(orders).findGlobalByDispatchNo("DSP-1");
|
||||
locks.verify(orders).lockById("100", 1L);
|
||||
locks.verify(tasks).lockByDispatch("100", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRejectsPartialPickingWithoutStatusDrift() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1")));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("PICKING", order.getStatus());
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertFalse(first.replayed());
|
||||
verify(operations).insert((DispatchPdaOperation) any());
|
||||
|
||||
DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first);
|
||||
when(operations.findKey("100", "done-1")).thenReturn(replay);
|
||||
DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertTrue(second.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departRequiresFullyCompletedPickingAndPendingOutbound() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).updateById((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货"))
|
||||
.when(fleetBindings).requirePickingApproved(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete")));
|
||||
org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test void boundDispatchCannotDepartBeforeTripCoordination() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车"))
|
||||
.when(fleetBindings).requireCoordinatedDeparture(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulDeparturePersistsBusinessDepartureTime() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
item.setInboundCode("IN-1");
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(1L);
|
||||
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
|
||||
OutboundPalletItem outboundItem = new OutboundPalletItem();
|
||||
outboundItem.setWarehousePalletId(41L); outboundItem.setInboundCode("IN-1");
|
||||
outboundItem.setPackageCount(6); outboundItem.setPickedPackageCount(0); outboundItem.setStatus("WAIT_PICK");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
when(outboundPalletItems.findByOutbound("100", 1L)).thenReturn(List.of(outboundItem));
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart(
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertNotNull(order.getDepartAt());
|
||||
assertEquals(order.getDepartAt(), order.getUpdatedAt());
|
||||
verify(orders).updateById(order);
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
assertEquals(6, outboundItem.getPickedPackageCount());
|
||||
assertEquals("PICKED", outboundItem.getStatus());
|
||||
verify(outboundPalletItems).updateById(outboundItem);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED");
|
||||
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
|
||||
DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertTrue(result.replayed());
|
||||
}
|
||||
|
||||
private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception {
|
||||
DispatchPdaOperation value = new DispatchPdaOperation();
|
||||
value.setTaskId(11L); value.setOperationType(type); value.setStatus("COMPLETED");
|
||||
value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))));
|
||||
value.setResultJson(objectMapper.writeValueAsString(result));
|
||||
return value;
|
||||
}
|
||||
|
||||
@Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() {
|
||||
var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
|
||||
order.setVehiclePlate("CA-100");
|
||||
var task = task(11L, "100", "WH-1", "PDA_PENDING");
|
||||
var item = item(21L, "100", 2);
|
||||
item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.selectById(21L)).thenReturn(item);
|
||||
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L);
|
||||
when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
|
||||
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton);
|
||||
var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员",
|
||||
new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1"));
|
||||
assertEquals(1L, result.pickedPackages());
|
||||
var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks);
|
||||
sequence.verify(attribution).prepareNativeReturn("100", 101L);
|
||||
sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
|
||||
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"));
|
||||
sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null));
|
||||
sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L));
|
||||
org.mockito.Mockito.verifyNoInteractions(inventory, positions);
|
||||
}
|
||||
|
||||
private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
value.setOverseasWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) {
|
||||
DispatchPdaTask value = new DispatchPdaTask();
|
||||
value.setId(id); value.setDispatchOrderId(1L);
|
||||
value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchItem item(Long id, String customerId, int packages) {
|
||||
DispatchItem value = new DispatchItem();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
|
||||
value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages);
|
||||
return value;
|
||||
@Test void missingCustomerOutboundCannotBorrowAnotherCustomersDocument(){
|
||||
assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("102"),Instant.now())).hasMessageContaining("本客户实际出库单");verifyNoInteractions(picks,source,attribution,fleet,relabels);
|
||||
}
|
||||
private static DispatchContracts.CargoLine cargo(String customer,long order,long packages,String relabel){var c=mock(DispatchContracts.CargoLine.class);when(c.customerId()).thenReturn(customer);when(c.orderId()).thenReturn(order);when(c.packages()).thenReturn(packages);when(c.relabelNo()).thenReturn(relabel);return c;}
|
||||
private static DispatchContracts.Context context(List<DispatchContracts.CargoLine> cargo){var c=mock(DispatchContracts.Context.class);when(c.cargo()).thenReturn(cargo);when(c.dispatchId()).thenReturn(1L);when(c.dispatchNo()).thenReturn("DP-ONE");return c;}
|
||||
private static DispatchDeliveryModels.DepartureResult result(String... customers){return new DispatchDeliveryModels.DepartureResult(Arrays.stream(customers).map(c->new DispatchDeliveryModels.OutboundReference(c,null,"OUT-"+c,"OUTBOUNDED",0,0)).toList(),List.of(),0);}
|
||||
}
|
||||
|
||||
+9
-9
@@ -62,7 +62,7 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1);
|
||||
DispatchPdaPick pick = pick(81L, 91L, 5L, 50L);
|
||||
|
||||
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick);
|
||||
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick, null);
|
||||
|
||||
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L),
|
||||
new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result);
|
||||
@@ -94,7 +94,7 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(2L,102L,1L,1L),
|
||||
receipt(1L,101L,1L,1L),receipt(3L,102L,1L,1L)));
|
||||
when(orders.lockById(eq("t1"),anyLong())).thenAnswer(call -> new LastlegOrder());
|
||||
service().preparePicking("t1",91L,null);
|
||||
service().preparePicking("t1",91L);
|
||||
var sequence=inOrder(attributions,orders);
|
||||
sequence.verify(attributions).availableReceipts("t1",91L);
|
||||
sequence.verify(orders).lockById("t1",101L);sequence.verify(orders).lockById("t1",102L);
|
||||
@@ -105,15 +105,15 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L)));
|
||||
var held=new LastlegOrder();held.setDetained(true);
|
||||
when(orders.lockById("t1",101L)).thenReturn(held).thenReturn(null);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
verifyNoInteractions(history);
|
||||
}
|
||||
@Test void changedSourceSetAfterLockingFailsClosed() {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L)))
|
||||
.thenReturn(List.of(receipt(2L,102L,1L,1L)));
|
||||
when(orders.lockById("t1",101L)).thenReturn(new LastlegOrder());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong());
|
||||
}
|
||||
|
||||
@@ -125,22 +125,22 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1);
|
||||
var service = service();
|
||||
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)),
|
||||
service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L));
|
||||
service.attribute("t1", pick(81L, 91L, 2L, 0L), 201L));
|
||||
var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class);
|
||||
verify(attributions).insert(capture.capture());
|
||||
assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId());
|
||||
when(returnReceipts.picked("t1", 201L)).thenReturn(2L);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L));
|
||||
() -> service.attribute("t1", pick(82L, 91L, 1L, 0L), 201L));
|
||||
}
|
||||
|
||||
@Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() {
|
||||
@Test void selectedReturnCannotLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() {
|
||||
var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt();
|
||||
receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L);
|
||||
when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt));
|
||||
when(returnReceipts.lock("t1", 201L)).thenReturn(receipt);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L));
|
||||
() -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 201L));
|
||||
verify(attributions, never()).insert(any(DispatchPdaPickOrder.class));
|
||||
verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong());
|
||||
verify(attributions, never()).availableReceipts(anyString(), anyLong());
|
||||
|
||||
+3
@@ -36,6 +36,9 @@ public final class DispatchPortCompileContract {
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本");
|
||||
}
|
||||
require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位");
|
||||
require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位");
|
||||
require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留");
|
||||
require(!fields(DispatchCommands.Create.class).contains("customerId"), "整车创建无单客户头部");
|
||||
require(!fields(DispatchContracts.Context.class).contains("customerId"), "整车上下文无伪客户");
|
||||
require(fields(DispatchContracts.CargoLine.class).contains("customerId"), "真实来源保留客户");
|
||||
|
||||
+5
-8
@@ -22,7 +22,7 @@ public final class DispatchPublicJsonContract {
|
||||
/** 测试执行器不允许实例化。 */
|
||||
private DispatchPublicJsonContract() { }
|
||||
|
||||
/** 覆盖缺版本、显式零、预约只写引用及所有动作结果持久JSON恢复。 */
|
||||
/** 覆盖缺版本、显式零、移除车辆预约字段及所有动作结果持久JSON恢复。 */
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectMapper requests = mapper();
|
||||
for (Class<?> request : new Class<?>[] {
|
||||
@@ -42,11 +42,8 @@ public final class DispatchPublicJsonContract {
|
||||
Object zero = requests.readValue("{\"recordVersion\":0}", request);
|
||||
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
|
||||
}
|
||||
var reference = requests.readValue("{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0}",
|
||||
DispatchCommands.AppointmentReference.class);
|
||||
require("EXISTING-AP".equals(reference.referenceNo()), "预约只保留权威引用");
|
||||
rejectsMissing(requests, DispatchCommands.AppointmentReference.class,
|
||||
"{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0,\"status\":\"CONFIRMED\",\"appointmentAt\":\"2026-10-19T00:00:00Z\"}");
|
||||
rejectsMissing(requests, DispatchCommands.Update.class,
|
||||
"{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}");
|
||||
|
||||
Instant now = Instant.parse("2026-10-19T00:00:00Z");
|
||||
var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1);
|
||||
@@ -80,7 +77,7 @@ public final class DispatchPublicJsonContract {
|
||||
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
|
||||
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
|
||||
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; appointment reference; all action JSON restart/replay)");
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
|
||||
}
|
||||
|
||||
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
|
||||
@@ -97,7 +94,7 @@ public final class DispatchPublicJsonContract {
|
||||
private static void rejectsMissing(ObjectMapper mapper, Class<?> type, String payload) throws Exception {
|
||||
try { mapper.readValue(payload, type); }
|
||||
catch (JsonProcessingException expected) { return; }
|
||||
throw new AssertionError("请求必须拒绝缺失/非法版本或伪造预约字段:" + type.getSimpleName());
|
||||
throw new AssertionError("请求必须拒绝缺失/非法版本或已移除字段:" + type.getSimpleName());
|
||||
}
|
||||
|
||||
/** 离线合同失败时终止,不模拟接口成功。 */
|
||||
|
||||
+194
-7
@@ -35,6 +35,166 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
|
||||
public class DispatchAggregatePersistenceTest {
|
||||
/** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
|
||||
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.allowFile(81L);var before=e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.files,times(2)).metadata(71L,81L);verify(e.files,times(2)).download(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
assertThat(e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number)).isEqualTo(before);
|
||||
}
|
||||
|
||||
/** 主档后来换证不改写本车冻结证照;旧附件必须仍满足该司机的历史关联规则。 */
|
||||
@Test void frozenHistoricalDriverLicenseRemainsReadableAfterMasterReplacement() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(1L);e.allowFile(81L);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.history,times(2)).selectCount(any());verify(e.files,times(2)).metadata(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 外配附件仍限本车证据;不能因为共用DRIVER展示类型而改走正式司机档案。 */
|
||||
@Test void externalDriverEvidenceRetainsExactDispatchOwnerForBothReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.EXTERNAL,null,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(81L,"external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,81L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,81L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,81L)).containsExactly(4,5,6);
|
||||
verify(e.dispatchFiles).metadata(dispatch,kind,81L);verify(e.dispatchFiles).download(dispatch,kind,81L);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
|
||||
/** 冻结引用不绕过司机当前或历史关联,提供者明确拒绝后不得尝试排车类别。 */
|
||||
@Test void unlinkedFrozenMasterFileCannotFallBackToDispatchEvidence() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(0L);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
verifyNoInteractions(e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** File拒绝主档四元组时直接失败,不以另一业务类别或其他司机重新读取。 */
|
||||
@Test void masterFileProviderRejectionCannotFallBackToAnotherCategory() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
when(e.files.metadata(71L,81L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */
|
||||
@Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"new-external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,91L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,91L)).containsExactly(4,5,6);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
assertThat(e.f.db.queryForObject("SELECT resources_json FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number)).contains("MASTER").doesNotContain("91");
|
||||
}
|
||||
|
||||
/** 未列入MASTER冻结集合或声明为外配的档案文件只能接受本车四元组核验,失败后不转司机域。 */
|
||||
@Test void foreignOrWrongModeMasterFilesNeverGainDriverDomainAccess() throws Exception {
|
||||
for(var mode:List.of(ResourceMode.MASTER,ResourceMode.EXTERNAL)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(mode,71L,mode==ResourceMode.MASTER?List.of(81L):List.of(91L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
when(e.dispatchFiles.download(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中冻结文件集合仍须有本车真实正数司机身份,不能拿车号或其他司机替代。 */
|
||||
@Test void masterFileWithMissingOrInvalidDriverIdentityFailsBeforeAnyFileProvider() throws Exception {
|
||||
for(Long driverId:Arrays.asList(null,0L,-1L)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,driverId,List.of(81L));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无法解释的资源模式不选择任何文件域,防止异常快照放宽读取。 */
|
||||
@Test void unknownDriverModeFailsBeforeEitherFileProvider() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET resources_json=REPLACE(resources_json,'MASTER','UNKNOWN') WHERE dispatch_no=?",number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 使用真实排车Mapper/资源JSON及司机Service,只替代外部文件内容和主档持久提供者。 */
|
||||
private static final class DriverEvidenceFixture {
|
||||
final Fixture f;final DriverMapper driverRows=mock(DriverMapper.class);final DriverHistoryMapper history=mock(DriverHistoryMapper.class);
|
||||
final DriverFileGateway files=mock(DriverFileGateway.class);final DriverEntity driver=new DriverEntity();
|
||||
final com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway dispatchFiles;
|
||||
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
|
||||
DriverEvidenceFixture() throws Exception {
|
||||
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
|
||||
var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
|
||||
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
|
||||
}
|
||||
/** 在真实主单保存服务端资源快照,HTTP请求本身不能指定司机、模式或允许文件集合。 */
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
void allowFile(long fileId) {
|
||||
var metadata=new DriverModels.File(Long.toString(fileId),"driver.pdf","application/pdf",3L);
|
||||
when(files.metadata(71L,fileId)).thenReturn(metadata);when(files.download(71L,fileId)).thenReturn(new byte[]{1,2,3});
|
||||
}
|
||||
}
|
||||
/** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */
|
||||
@Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of())));
|
||||
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
|
||||
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
|
||||
assertThat(created.recordVersion()).isZero();
|
||||
var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED");
|
||||
verifyNoInteractions(facts,sources,serial);
|
||||
}
|
||||
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */
|
||||
@Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -42,10 +202,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
var removeRequest=new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-original","重新分车");
|
||||
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest));
|
||||
assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501 AND active_warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();
|
||||
var newOwner=f.tx.execute(s->f.service.create(f.actor,f.create("new-owner",List.of(a))));
|
||||
assertThat(f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId());
|
||||
assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId());
|
||||
var cancelRequest=new DispatchCommands.ReasonCommand(removed.recordVersion(),"cancel-original","本趟取消");
|
||||
var cancelled=f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
@@ -54,6 +214,23 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(t->f.service.create(f.actor,f.create("cancel-history",List.of(source))));var root=f.repository.find(created.dispatchNo());
|
||||
var outbound=new com.baiyue.lastleg.delivery.entity.OutboundOrder();outbound.setId(123L);outbound.setCustomerId("101");outbound.setOutboundNo("OUT-HISTORY");outbound.setDispatchOrderId(root.getId());outbound.setStatus("WAIT_OUTBOUND");outbound.setPackageCount(3);outbound.setPalletCount(1);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
|
||||
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-history-original","取消未执行排车");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("关联出库");
|
||||
f.db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=123");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isOne();assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
|
||||
}
|
||||
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
|
||||
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
|
||||
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
}
|
||||
/** 作业读取返回真实明细身份,路线保存重排实际站序且保留起运仓。 */
|
||||
@Test void operationItemsAndRouteUseActualIdsAndPersistedStopOrder() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -223,7 +400,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
var mapperTypes=List.of(DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchExecutionReadMapper.class,DispatchCommandMapper.class,DispatchSourceMapper.class,
|
||||
DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,FleetResourceMapper.class,
|
||||
OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class);
|
||||
OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class);
|
||||
mapperTypes.forEach(config::addMapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
session=new SqlSessionTemplate(factory.getObject());
|
||||
@@ -233,19 +410,19 @@ public class DispatchAggregatePersistenceTest {
|
||||
heads.add(column+" "+type(field.getType())+(column.equals("id")?" PRIMARY KEY":""));
|
||||
}
|
||||
db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",heads)+",UNIQUE(dispatch_no),UNIQUE(trip_id))");
|
||||
for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class))
|
||||
for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class))
|
||||
for(var parent:mapper.getGenericInterfaces()) if(parent instanceof java.lang.reflect.ParameterizedType generic && generic.getRawType()==com.baomidou.mybatisplus.core.mapper.BaseMapper.class) {
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)generic.getActualTypeArguments()[0]); var columns=new ArrayList<String>(); columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:table.getFieldList()) if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !Set.of("customer_id","review_json").contains(field.getColumn())) columns.add(field.getColumn()+" "+type(field.getPropertyType()));
|
||||
db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD active_warehouse_pallet_id BIGINT UNIQUE");
|
||||
db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
|
||||
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)");
|
||||
@@ -273,10 +450,16 @@ public class DispatchAggregatePersistenceTest {
|
||||
fleet=new FleetTripExecutionService(session.getMapper(FleetTripMapper.class),session.getMapper(FleetTripDispatchMapper.class),session.getMapper(FleetResourceMapper.class),gateway,fleetService,drivers,vehicles,json,reservations);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fleet,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
var rules=mock(DispatchService.class);
|
||||
var emptySnapshot=new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"EMPTY",true,List.of());
|
||||
when(rules.lockExecutionSources(anyString(),anyList())).thenReturn(emptySnapshot);
|
||||
when(rules.lockWholeSources(eq("9"),anyList())).thenAnswer(invocation -> {
|
||||
List<DispatchCommands.CargoSelection> request=invocation.getArgument(1);
|
||||
return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList();
|
||||
});
|
||||
when(rules.lockAdditionalSources(eq("9"),anyList(),anyList())).thenAnswer(invocation -> {
|
||||
List<DispatchCommands.CargoSelection> request=invocation.getArgument(1);
|
||||
return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList();
|
||||
});
|
||||
service=new DispatchAggregateService(repository,
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
@@ -285,6 +468,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"executionReads",executionReads);
|
||||
var origins=new DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"sourceOrigins",origins);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"relabelSources",new DispatchItemSourceService(origins,mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class),json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"physicalClaims",new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class))));
|
||||
}
|
||||
|
||||
/** 提供者固定事实代表真实客户来源,调用请求自身没有客户字段。 */
|
||||
@@ -322,7 +509,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
|
||||
db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,?,?,?,?,NULL,NULL,?,?)",source,customer,source,9000+source,source,packages,packages*2);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2);
|
||||
}
|
||||
|
||||
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
|
||||
|
||||
+10
@@ -29,6 +29,16 @@ class DispatchAppointmentAllocationsTest {
|
||||
verify(provider).lockBatches(new TreeMap<>(Map.of("FBA_APPOINTMENT:9",4L,"FBA_APPOINTMENT:2",0L)));
|
||||
verifyNoMoreInteractions(provider);
|
||||
}
|
||||
@Test void bulkCandidatesUseEveryRealOwnerOnceAndRequireACompleteAuthorityResponse() {
|
||||
var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider);
|
||||
var a=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("101").planPalletId(11L);
|
||||
var b=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("102").planPalletId(12L);
|
||||
var identities=List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",11L),new AppointmentWorkDispatchPort.CandidatePhysical("102",12L));
|
||||
var references=Map.of(identities.get(0),new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:7","AP-7",0L,"101",List.of(new AppointmentWorkDispatchPort.BatchSource("ORDER:1","FBA:ONT8",3)))),List.of(),false),identities.get(1),new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("原批次待确认"),false));
|
||||
when(provider.candidateBatches(identities)).thenReturn(references);
|
||||
assertThat(service.candidates(List.of(a,b,a))).isEqualTo(references);verify(provider,times(1)).candidateBatches(identities);
|
||||
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
|
||||
}
|
||||
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
|
||||
}
|
||||
|
||||
+38
-48
@@ -1,57 +1,47 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
/** 主单内部桥接使用实际主单、明细、资源、占用、命令和历史事务;Provider SQL由其直接测试覆盖。 */
|
||||
class DispatchAppointmentCancellationPersistenceTest {
|
||||
/** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */
|
||||
@Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class);
|
||||
mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject());
|
||||
for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) {
|
||||
if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue;
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)p.getActualTypeArguments()[0]);var columns=new ArrayList<String>();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
@Test void removingOneCustomersOriginalCargoKeepsTruckAndSiblingAndRecoversTheOriginalCommand() throws Exception {
|
||||
var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"appointment-remove-original","原预约整票移出");
|
||||
var removed=s.f.tx.execute(tx->{s.f.service.lockExistingAppointmentDispatches(s.f.actor,List.of(s.created.dispatchNo()));return s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true);});
|
||||
assertThat(removed.status()).isEqualTo(DispatchTypes.Status.ACTIVE);assertThat(removed.counts().packageCount()).isEqualTo(5);
|
||||
assertThat(s.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_item",String.class)).isEqualTo("102");
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();assertThat(s.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=502",Long.class)).isOne();
|
||||
long history=s.count("ll_dispatch_history");assertThat(s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true)).replayed()).isTrue();assertThat(s.count("ll_dispatch_history")).isEqualTo(history);
|
||||
verify(s.provider,times(1)).removeItem(s.root,s.item);
|
||||
}
|
||||
@Test void parentFailureRollsBackCargoClaimsReviewsHistoryAndChildLedger() throws Exception {
|
||||
var s=new Scenario();long commands=s.count("ll_dispatch_command"),history=s.count("ll_dispatch_history");
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->{s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"parent-rollback","原预约回滚"),s.physical(),s.expected(),List.of(),true);throw new IllegalStateException("parent rejected");})).hasMessageContaining("parent rejected");
|
||||
assertThat(s.count("ll_dispatch_item")).isEqualTo(2);assertThat(s.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Long.class)).isEqualTo(8);assertThat(s.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(s.created.recordVersion());assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void wrongOwnerPartialQuantityOrChangedOriginalAllocationCannotMutateAnyCargo() throws Exception {
|
||||
var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-wrong-original","范围核验");
|
||||
for(var physical:List.of(List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"102",1L,"RECEIVED",3)),List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"101",1L,"RECEIVED",2))))assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,physical,s.expected(),List.of(),true))).hasMessageContaining("完整数量");
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),List.of(),List.of(),true))).hasMessageContaining("分配集合");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider);
|
||||
}
|
||||
@Test void realPickBlocksAppointmentRemovalEvenWhenMainCountersAreZero() throws Exception {
|
||||
var s=new Scenario();s.f.picked(1,"101",501,1);
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-picked","真实扫码"),s.physical(),s.expected(),List.of(),true))).hasMessageContaining("实际扫码");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider);
|
||||
}
|
||||
private static final class Scenario {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();final DispatchContracts.CommandResult created;final long root,item;final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class);
|
||||
Scenario() throws Exception {
|
||||
created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-main",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));root=f.repository.find(created.dispatchNo()).getId();item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(900,0,'AP-ORIGINAL')");f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",root,item);
|
||||
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
}
|
||||
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)");
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')");
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
|
||||
var allocations=session.getMapper(AppointmentWorkDispatchMapper.class);
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
|
||||
var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
|
||||
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')");
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
|
||||
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull();
|
||||
List<DispatchAppointmentAmendmentPort.PhysicalItem> physical(){return List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(item,"101",1L,"RECEIVED",3));}
|
||||
List<DispatchContracts.BatchAllocation> expected(){return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:900","101","ORDER:9001","FBA:ONT8",3,0L));}
|
||||
long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
|
||||
}
|
||||
/** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user