feat: close overseas wallet and pallet cost flow

This commit is contained in:
2026-09-05 22:26:25 +08:00
parent f62283fb7f
commit 5fbd2bbd04
69 changed files with 1598 additions and 53 deletions
@@ -45,7 +45,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version());
}
@Override
@@ -122,7 +122,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
}
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version());
}
@Override
public WarehousePalletReference resolvePallet(long palletId) {
WarehousePalletResponse response = client.get().uri("/internal/v1/warehouse-pallets/resolve/id/{id}", palletId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); })
.body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
@@ -187,7 +200,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {}
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
@@ -8,6 +8,7 @@ public interface WarehouseResourceGateway {
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
WarehousePalletReference resolvePallet(String palletCode);
WarehousePalletReference resolvePallet(long palletId);
default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
@@ -19,9 +20,14 @@ public interface WarehouseResourceGateway {
}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
public WarehousePalletReference(long palletId, String palletNo, long locationId) {
this(palletId, palletNo, 0L, locationId, null, null, 0);
this(palletId, palletNo, 0L, locationId, null, null, java.math.BigDecimal.ZERO, 0);
}
public WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {
this(palletId, palletNo, warehouseId, locationId, status, fbaWarehouseCode,
java.math.BigDecimal.ZERO, version);
}
}
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.inboundplan.service.DestuffingPalletCostService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder;
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class DestuffingPalletCostJob {
private final DestuffingPalletCostService service;
@Scheduled(fixedDelayString = "${baiyue.destuff-pallet-cost.fixed-delay-ms:10000}")
public void allocateReadyContainers() {
for (String bookingId : service.readyBookingIds()) {
try {
service.generateIfReady(bookingId, "SYSTEM");
} catch (RuntimeException exception) {
log.warn("Destuff pallet cost allocation will retry bookingId={}", bookingId, exception);
}
}
}
}
@@ -0,0 +1,90 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class DestuffingPalletCostService {
private final JdbcTemplate jdbc;
private final PalletPhysicalGateway pallets;
private final OverseasFeeSourceMapper feeSources;
private final OverseasFeeOutboxService feeOutbox;
public List<String> readyBookingIds() {
return jdbc.queryForList("SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MIN(t.completed_at) LIMIT 50", String.class);
}
@Transactional
public void generateIfReady(String bookingId, String operatorId) {
Integer exists = jdbc.queryForObject("SELECT COUNT(*) FROM ll_destuff_pallet_cost WHERE source_container_booking_id=?", Integer.class, bookingId);
if (exists != null && exists > 0) return;
List<Map<String,Object>> plans = jdbc.queryForList("SELECT p.id,p.customer_id,p.container_no,p.overseas_warehouse_id,t.status FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id LEFT JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id WHERE o.source_container_booking_id=? ORDER BY p.customer_id,p.id", bookingId);
if (plans.isEmpty() || plans.stream().anyMatch(row -> !"COMPLETED".equals(row.get("status")))) return;
List<Long> palletIds = jdbc.queryForList("SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL ORDER BY pp.warehouse_pallet_id", Long.class, bookingId);
BigDecimal totalCost = BigDecimal.ZERO;
for (Long palletId : palletIds) totalCost = totalCost.add(pallets.resolve(palletId).unitPrice());
totalCost = totalCost.setScale(2, RoundingMode.HALF_UP);
if (palletIds.isEmpty() || totalCost.signum() <= 0) return;
List<CustomerVolume> volumes = jdbc.query("SELECT a.customer_id,SUM(a.actual_volume_cbm) actual_volume FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? GROUP BY a.customer_id ORDER BY a.customer_id",
(rs, n) -> new CustomerVolume(rs.getString("customer_id"), rs.getBigDecimal("actual_volume")), bookingId);
BigDecimal totalVolume = volumes.stream().map(CustomerVolume::volume).reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalVolume.signum() <= 0) throw new IllegalStateException("拆柜打托费用缺少客户实收体积,不能分摊");
long costId = IdWorker.getId(); String containerNo = String.valueOf(plans.get(0).get("container_no"));
Long warehouseId = plans.get(0).get("overseas_warehouse_id") == null ? null : ((Number) plans.get(0).get("overseas_warehouse_id")).longValue();
jdbc.update("INSERT INTO ll_destuff_pallet_cost(id,source_container_booking_id,container_no,warehouse_id,pallet_count,total_actual_volume_cbm,total_cost,currency,status,created_at) VALUES(?,?,?,?,?,?,?,'CNY','ALLOCATED',NOW(3))",
costId, bookingId, containerNo, warehouseId, palletIds.size(), totalVolume, totalCost);
List<CustomerVolume> ordered = new ArrayList<>(volumes);
ordered.sort(Comparator.comparing(CustomerVolume::customerId));
List<BigDecimal> amounts = allocate(totalCost, ordered.stream().map(CustomerVolume::volume).toList());
for (int i=0; i<ordered.size(); i++) {
CustomerVolume volume = ordered.get(i);
BigDecimal amount = amounts.get(i);
OverseasFeeSource fee = fee(costId, bookingId, containerNo, warehouseId, volume, amount, operatorId);
feeSources.insert(fee); feeOutbox.publish(fee, "PUBLISHED");
jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,?,NOW(3))",
IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount, fee.getSourceFeeId());
}
}
static List<BigDecimal> allocate(BigDecimal totalCost, List<BigDecimal> volumes) {
BigDecimal totalVolume = volumes.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalVolume.signum() <= 0) throw new IllegalArgumentException("实收体积必须大于零");
List<BigDecimal> result = new ArrayList<>(); BigDecimal allocated = BigDecimal.ZERO;
for (int i = 0; i < volumes.size(); i++) {
BigDecimal amount = i == volumes.size() - 1 ? totalCost.subtract(allocated)
: totalCost.multiply(volumes.get(i)).divide(totalVolume, 2, RoundingMode.HALF_UP);
result.add(amount); allocated = allocated.add(amount);
}
return result;
}
private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) {
LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource();
v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId());
v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType("CONTAINER_DESTUFFING");
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
}
private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}}
private record CustomerVolume(String customerId, BigDecimal volume){}
}