feat: close overseas wallet and pallet cost flow
This commit is contained in:
+3
@@ -11,6 +11,7 @@ public class LastlegRestClientProperties {
|
||||
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
|
||||
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
|
||||
|
||||
@@ -20,6 +21,8 @@ public class LastlegRestClientProperties {
|
||||
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
|
||||
public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; }
|
||||
public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); }
|
||||
public ClientTimeouts getProcurementFile() { return procurementFile; }
|
||||
public void setProcurementFile(ClientTimeouts value) { procurementFile = required(value, "procurement-file"); }
|
||||
public ClientTimeouts getWarehouseResource() { return warehouseResource; }
|
||||
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
|
||||
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
|
||||
|
||||
+4
@@ -16,6 +16,7 @@ import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -45,6 +46,7 @@ public class LastlegOrderController {
|
||||
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
|
||||
@Resource private LastlegOrderService orderService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
|
||||
|
||||
@PostMapping("/internal/lastleg/events")
|
||||
@Operation(summary = "Receive a first-leg loading or actual shipping-node event",
|
||||
@@ -56,6 +58,8 @@ public class LastlegOrderController {
|
||||
int consumed;
|
||||
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
|
||||
consumed = shippingNodeConsumer.consume(event);
|
||||
} else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) {
|
||||
consumed = walletCommandCompletedConsumer.consume(event);
|
||||
} else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
|
||||
consumed = consumer.consume(event);
|
||||
} else {
|
||||
|
||||
+16
-3
@@ -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
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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){}
|
||||
}
|
||||
+11
@@ -45,6 +45,17 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
|
||||
return response.data();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PalletReference resolve(long palletId) {
|
||||
ResolveEnvelope 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(ResolveEnvelope.class);
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet service returned no usable response");
|
||||
return response.data();
|
||||
}
|
||||
|
||||
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
|
||||
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
|
||||
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
|
||||
|
||||
+8
-1
@@ -2,13 +2,20 @@ package com.baiyue.lastleg.palletops.gateway;
|
||||
|
||||
public interface PalletPhysicalGateway {
|
||||
PalletReference resolve(String palletCode);
|
||||
PalletReference resolve(long palletId);
|
||||
LocationReference resolveLocation(long warehouseId, long locationId);
|
||||
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
|
||||
String operationKey, String reason);
|
||||
void compensate(long warehouseId, Transition transition);
|
||||
|
||||
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, int version) { }
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
|
||||
public PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, int version) {
|
||||
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
|
||||
java.math.BigDecimal.ZERO, version);
|
||||
}
|
||||
}
|
||||
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
|
||||
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
|
||||
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
|
||||
|
||||
+22
@@ -21,6 +21,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
@@ -91,6 +94,25 @@ public class PlatformOverseasProcurementController {
|
||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
|
||||
public ApiResponse<OverseasProcurementResponses.FileInfo> uploadFile(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @PathVariable String category, @RequestPart("file") MultipartFile file) {
|
||||
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.uploadFile(requiredCustomer(customerId), orderNo,
|
||||
String.valueOf(user.userId()), category.trim().toUpperCase(), file));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/fees")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
|
||||
public ApiResponse<OverseasProcurementResponses.Fee> createFee(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) {
|
||||
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.createFee(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
|
||||
+7
@@ -53,6 +53,13 @@ public final class OverseasProcurementRequests {
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record CreateFee(
|
||||
@NotBlank @Size(max = 32) String feeItemCode,
|
||||
@NotBlank @Size(max = 128) String feeItemName,
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
|
||||
@NotNull @Positive Long voucherFileId,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record Update(
|
||||
@NotNull @Min(0) Long version,
|
||||
@Size(max = 128) String purchaseCategory,
|
||||
|
||||
+10
-1
@@ -27,6 +27,14 @@ public final class OverseasProcurementResponses {
|
||||
String idempotencyKey, String changeDetail, String remark,
|
||||
LocalDateTime createdAt) {}
|
||||
|
||||
public record FileInfo(Long id, String category, Long fileId, String fileName,
|
||||
String contentType, long sizeBytes, LocalDateTime createdAt) {}
|
||||
|
||||
public record Fee(Long id, String feeNo, String feeItemCode, String feeItemName,
|
||||
BigDecimal amount, String currency, Long voucherFileId,
|
||||
String voucherFileName, String remark, String walletStatus,
|
||||
String walletErrorMessage, LocalDateTime createdAt) {}
|
||||
|
||||
public record Detail(
|
||||
Summary summary, String productUrl, String receivingAddress,
|
||||
String contactName, String contactPhone,
|
||||
@@ -34,5 +42,6 @@ public final class OverseasProcurementResponses {
|
||||
String overseasCarrier, String overseasTrackingNo,
|
||||
LocalDateTime estimatedArrivalAt, String remark,
|
||||
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
|
||||
List<History> histories, List<OutboundDetail> outboundDetails) {}
|
||||
List<History> histories, List<OutboundDetail> outboundDetails,
|
||||
List<FileInfo> files, List<Fee> fees) {}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.procurement.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_procurement_fee")
|
||||
public class OverseasProcurementFee {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long orderId;
|
||||
private String customerId;
|
||||
private String feeNo;
|
||||
private String feeItemCode;
|
||||
private String feeItemName;
|
||||
private BigDecimal amount;
|
||||
private String currency;
|
||||
private Long voucherFileId;
|
||||
private String voucherFileName;
|
||||
private String remark;
|
||||
private String walletStatus;
|
||||
private String walletErrorCode;
|
||||
private String walletErrorMessage;
|
||||
private String walletCommandEventId;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.procurement.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;
|
||||
|
||||
@Data
|
||||
@TableName("ll_overseas_procurement_file")
|
||||
public class OverseasProcurementFile {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long orderId;
|
||||
private String customerId;
|
||||
private String category;
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
private String contentType;
|
||||
private Long sizeBytes;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.procurement.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HttpProcurementFileGateway implements ProcurementFileGateway {
|
||||
private final RestClient.Builder builder;
|
||||
private final LastlegRestClientFactory factory;
|
||||
private final LastlegRestClientProperties properties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String token;
|
||||
private RestClient client;
|
||||
@PostConstruct void initialize() { client = factory.build(builder, baseUrl, properties.getProcurementFile()); }
|
||||
|
||||
@Override
|
||||
public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择文件");
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); }
|
||||
String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER";
|
||||
Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||
Long.parseLong(customerId), businessType, orderId)
|
||||
.header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
|
||||
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"采购文件上传失败"); }).body(Envelope.class);
|
||||
if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null)
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回采购文件元数据");
|
||||
Metadata data = response.data();
|
||||
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||
}
|
||||
private record Envelope(String code, String message, Metadata data) { }
|
||||
private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.lastleg.procurement.gateway;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
public interface ProcurementFileGateway {
|
||||
UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file);
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.lastleg.procurement.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> { }
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.lastleg.procurement.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface OverseasProcurementFileMapper extends BaseMapper<OverseasProcurementFile> { }
|
||||
+109
-4
@@ -9,6 +9,14 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
||||
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.wallet.WalletResultHandler;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
@@ -34,10 +42,11 @@ import java.util.Map;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class OverseasProcurementService {
|
||||
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
|
||||
public class OverseasProcurementService implements WalletResultHandler {
|
||||
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
|
||||
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
|
||||
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
@@ -49,6 +58,10 @@ public class OverseasProcurementService {
|
||||
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
||||
@Resource private PartnerWarehouseDirectory warehouseDirectory;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private OverseasProcurementFileMapper procurementFiles;
|
||||
@Resource private OverseasProcurementFeeMapper procurementFees;
|
||||
@Resource private ProcurementFileGateway fileGateway;
|
||||
@Resource private WalletCommandOutboxService walletCommands;
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
|
||||
@@ -63,7 +76,7 @@ public class OverseasProcurementService {
|
||||
order.setId(IdWorker.getId());
|
||||
order.setCustomerId(customerId);
|
||||
order.setCustomerName(trim(request.customerName()));
|
||||
order.setOrderNo(orderNoGenerator.next("PROCUREMENT", request.receivingAddress(), order.getId()));
|
||||
order.setOrderNo(orderNoGenerator.next(customerId, request.customerName()));
|
||||
order.setPurchaseCategory(request.purchaseCategory().trim());
|
||||
order.setProductName(request.productName().trim());
|
||||
order.setProductSpecification(trim(request.productSpecification()));
|
||||
@@ -148,7 +161,90 @@ public class OverseasProcurementService {
|
||||
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||
value.getChangeDetail(), value.getRemark(),
|
||||
value.getCreatedAt())).toList(), outboundDetails(order));
|
||||
value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId,
|
||||
String category, MultipartFile file) {
|
||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||
if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER");
|
||||
ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file);
|
||||
OverseasProcurementFile row = new OverseasProcurementFile();
|
||||
row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category);
|
||||
row.setFileId(uploaded.fileId()); row.setFileName(uploaded.fileName()); row.setContentType(uploaded.contentType());
|
||||
row.setSizeBytes(uploaded.sizeBytes()); row.setCreatedBy(operatorId); row.setCreatedAt(LocalDateTime.now());
|
||||
procurementFiles.insert(row);
|
||||
return file(row);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId,
|
||||
OverseasProcurementRequests.CreateFee request) {
|
||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees");
|
||||
OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
|
||||
.eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId)
|
||||
.eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId()));
|
||||
if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OverseasProcurementFee row = new OverseasProcurementFee();
|
||||
row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId);
|
||||
row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase());
|
||||
row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY");
|
||||
row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark()));
|
||||
row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now);
|
||||
procurementFees.insert(row);
|
||||
String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(),
|
||||
row.getAmount(), "海外采购费用冻结:" + orderNo, 1L);
|
||||
row.setWalletCommandEventId(eventId); procurementFees.updateById(row);
|
||||
return fee(row);
|
||||
}
|
||||
|
||||
@Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); }
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handle(WalletCommandCompletedV1 result) {
|
||||
OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>()
|
||||
.eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId()))
|
||||
.eq(OverseasProcurementFee::getFeeNo, result.businessNo()));
|
||||
if (row == null) throw notFound("Procurement fee not found for wallet result");
|
||||
if ("SUCCEEDED".equals(result.result())) {
|
||||
row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); });
|
||||
row.setWalletErrorCode(null); row.setWalletErrorMessage(null);
|
||||
} else {
|
||||
row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage());
|
||||
}
|
||||
row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row);
|
||||
if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) {
|
||||
OverseasProcurementOrder order = orders.selectById(row.getOrderId());
|
||||
if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) {
|
||||
walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null,
|
||||
"海外采购完成入库,结算费用", 2L);
|
||||
row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<OverseasProcurementResponses.FileInfo> files(OverseasProcurementOrder order) {
|
||||
return procurementFiles.selectList(new LambdaQueryWrapper<OverseasProcurementFile>()
|
||||
.eq(OverseasProcurementFile::getOrderId, order.getId()).orderByAsc(OverseasProcurementFile::getCreatedAt))
|
||||
.stream().map(this::file).toList();
|
||||
}
|
||||
private OverseasProcurementResponses.FileInfo file(OverseasProcurementFile value) {
|
||||
return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(),
|
||||
value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt());
|
||||
}
|
||||
private List<OverseasProcurementResponses.Fee> fees(OverseasProcurementOrder order) {
|
||||
return procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
|
||||
.eq(OverseasProcurementFee::getOrderId, order.getId()).orderByAsc(OverseasProcurementFee::getCreatedAt))
|
||||
.stream().map(this::fee).toList();
|
||||
}
|
||||
private OverseasProcurementResponses.Fee fee(OverseasProcurementFee value) {
|
||||
return new OverseasProcurementResponses.Fee(value.getId(), value.getFeeNo(), value.getFeeItemCode(), value.getFeeItemName(),
|
||||
value.getAmount(), value.getCurrency(), value.getVoucherFileId(), value.getVoucherFileName(), value.getRemark(),
|
||||
value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -192,6 +288,7 @@ public class OverseasProcurementService {
|
||||
Map<String, Object> before = editableSnapshot(order);
|
||||
if (request.inboundQuantity() != null) {
|
||||
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock");
|
||||
if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity");
|
||||
order.setInboundQuantity(request.inboundQuantity());
|
||||
}
|
||||
setIfText(request.purchaseCategory(), order::setPurchaseCategory);
|
||||
@@ -213,6 +310,14 @@ public class OverseasProcurementService {
|
||||
orders.updateById(order);
|
||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||
null, changeDetail(before, after), null, now);
|
||||
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
|
||||
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
|
||||
.eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) {
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null,
|
||||
"海外采购完成入库,结算费用", 2L);
|
||||
fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee);
|
||||
}
|
||||
}
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.procurement.service;
|
||||
|
||||
import net.sourceforge.pinyin4j.PinyinHelper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
|
||||
@Component
|
||||
public class ProcurementOrderNumberGenerator {
|
||||
private final JdbcTemplate jdbc;
|
||||
public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
|
||||
@Transactional
|
||||
public String next(String customerId, String customerName) {
|
||||
LocalDate date = LocalDate.now();
|
||||
jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)",
|
||||
customerId, date);
|
||||
Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class);
|
||||
if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽");
|
||||
return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value);
|
||||
}
|
||||
|
||||
static String initials(String customerName) {
|
||||
if (!StringUtils.hasText(customerName)) return "KH";
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean latinWord = false;
|
||||
for (char value : customerName.trim().toCharArray()) {
|
||||
if (result.length() >= 8) break;
|
||||
if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) {
|
||||
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value);
|
||||
if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
|
||||
latinWord = false;
|
||||
} else if (Character.isLetterOrDigit(value)) {
|
||||
if (!latinWord) result.append(Character.toUpperCase(value));
|
||||
latinWord = true;
|
||||
} else {
|
||||
latinWord = false;
|
||||
}
|
||||
}
|
||||
String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
|
||||
public final class RelabelRequests {
|
||||
private RelabelRequests() {}
|
||||
@@ -21,6 +23,7 @@ public final class RelabelRequests {
|
||||
@NotNull LocalDateTime labelDeadline,
|
||||
@NotBlank @Size(max = 128) String contactName,
|
||||
@NotBlank @Size(max = 64) String contactPhone,
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.relabel.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public final class RelabelResponses {
|
||||
private RelabelResponses() {}
|
||||
@@ -10,6 +11,8 @@ public final class RelabelResponses {
|
||||
String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline,
|
||||
String contactName, String contactPhone, String status, int itemCount,
|
||||
int totalPackageCount, int notifiedPackageCount, String skuCodes,
|
||||
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
|
||||
String walletErrorMessage,
|
||||
LocalDateTime createdAt) {}
|
||||
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
|
||||
+7
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_relabel_application")
|
||||
@@ -24,6 +25,12 @@ public class RelabelApplication {
|
||||
private Integer itemCount;
|
||||
private Integer totalPackageCount;
|
||||
private Integer notifiedPackageCount;
|
||||
private BigDecimal chargeAmount;
|
||||
private String chargeCurrency;
|
||||
private String walletStatus;
|
||||
private String walletErrorCode;
|
||||
private String walletErrorMessage;
|
||||
private String walletCommandEventId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
|
||||
+50
-2
@@ -17,6 +17,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.wallet.WalletResultHandler;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -36,7 +39,7 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RelabelApplicationService {
|
||||
public class RelabelApplicationService implements WalletResultHandler {
|
||||
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
|
||||
|
||||
@@ -47,6 +50,7 @@ public class RelabelApplicationService {
|
||||
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private WalletCommandOutboxService walletCommands;
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
|
||||
@@ -109,6 +113,9 @@ public class RelabelApplicationService {
|
||||
application.setItemCount(request.items().size());
|
||||
application.setTotalPackageCount(total);
|
||||
application.setNotifiedPackageCount(notified);
|
||||
application.setChargeAmount(request.chargeAmount());
|
||||
application.setChargeCurrency("CNY");
|
||||
application.setWalletStatus("FREEZE_PENDING");
|
||||
application.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
application.setRequestHash(requestHash);
|
||||
application.setCreatedBy(trim(operatorId));
|
||||
@@ -116,6 +123,11 @@ public class RelabelApplicationService {
|
||||
application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, request.chargeAmount(), "换标订单费用冻结", 1L);
|
||||
application.setWalletCommandEventId(walletEventId);
|
||||
applications.updateById(application);
|
||||
|
||||
for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now);
|
||||
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
|
||||
insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(),
|
||||
@@ -172,11 +184,23 @@ public class RelabelApplicationService {
|
||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
|
||||
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
|
||||
validateTransition(application.getStatus(), next);
|
||||
if ("PROCESSING".equals(next) && !"FROZEN".equals(application.getWalletStatus())) {
|
||||
throw conflict("Relabel funds must be frozen before processing");
|
||||
}
|
||||
String before = application.getStatus();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
application.setStatus(next);
|
||||
application.setUpdatedAt(now);
|
||||
applications.updateById(application);
|
||||
if ("COMPLETED".equals(next)) {
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
} else if (Set.of("REJECTED", "CANCELLED").contains(next) && "FROZEN".equals(application.getWalletStatus())) {
|
||||
walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标取消,释放冻结", 2L);
|
||||
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
|
||||
}
|
||||
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
|
||||
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
@@ -221,7 +245,31 @@ public class RelabelApplicationService {
|
||||
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
|
||||
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
|
||||
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
|
||||
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt());
|
||||
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
|
||||
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
|
||||
value.getCreatedAt());
|
||||
}
|
||||
|
||||
@Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); }
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handle(WalletCommandCompletedV1 result) {
|
||||
RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo());
|
||||
if (value == null) throw notFound("Relabel application not found for wallet result");
|
||||
if ("SUCCEEDED".equals(result.result())) {
|
||||
value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); });
|
||||
value.setWalletErrorCode(null); value.setWalletErrorMessage(null);
|
||||
if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) {
|
||||
walletCommands.publish("RELEASE", result.customerId(), "RELABEL_ORDER", value.getRelabelNo(), null,
|
||||
"换标已取消,冻结成功后立即释放", 2L);
|
||||
value.setWalletStatus("RELEASE_PENDING");
|
||||
}
|
||||
} else {
|
||||
value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode());
|
||||
value.setWalletErrorMessage(result.errorMessage());
|
||||
}
|
||||
value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value);
|
||||
}
|
||||
|
||||
private RelabelResponses.Item item(RelabelItem value) {
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ import java.util.List;
|
||||
public class UnloadCompletedOutboxRepository {
|
||||
private static final List<String> OVERSEAS_TO_CN_TYPES = List.of(
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED);
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
@Resource private LastlegEventOutboxMapper mapper;
|
||||
|
||||
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.lastleg.wallet;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletCommandCompletedConsumer {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final List<WalletResultHandler> handlers;
|
||||
|
||||
@Transactional
|
||||
public int consume(EventEnvelope envelope) {
|
||||
WalletCommandCompletedV1 result = validate(envelope);
|
||||
List<String> existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId());
|
||||
if (!existing.isEmpty()) {
|
||||
if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷");
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))",
|
||||
envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope));
|
||||
} catch (DuplicateKeyException exception) { return 0; }
|
||||
WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst()
|
||||
.orElseThrow(() -> bad("没有可处理该资金结果的业务模块"));
|
||||
handler.handle(result);
|
||||
jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId());
|
||||
return 1;
|
||||
}
|
||||
|
||||
private WalletCommandCompletedV1 validate(EventEnvelope envelope) {
|
||||
try { EventEnvelopeValidator.requireValid(envelope); }
|
||||
catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); }
|
||||
if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != WalletCommandCompletedV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
|
||||
throw bad("资金结果事件类型、版本或区域无效");
|
||||
}
|
||||
if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) {
|
||||
throw bad("资金结果事件载荷哈希无效");
|
||||
}
|
||||
try {
|
||||
WalletCommandCompletedV1 result = objectMapper.treeToValue(envelope.payload(), WalletCommandCompletedV1.class);
|
||||
if (result.customerId() <= 0 || result.commandEventId() == null || result.commandEventId().isBlank()
|
||||
|| result.businessType() == null || result.businessType().isBlank()
|
||||
|| result.businessNo() == null || result.businessNo().isBlank()) throw bad("资金结果事件字段无效");
|
||||
return result;
|
||||
} catch (ResponseStatusException exception) { throw exception; }
|
||||
catch (Exception exception) { throw bad("资金结果事件载荷无法解析"); }
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw bad("资金结果事件序列化失败"); }
|
||||
}
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(BAD_REQUEST, message); }
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.lastleg.wallet;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandRequestedV1;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletCommandOutboxService {
|
||||
private final LastlegEventOutboxMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public String publish(String operation, long customerId, String businessType, String businessNo,
|
||||
BigDecimal amount, String remark, long aggregateVersion) {
|
||||
String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
|
||||
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
|
||||
if (existing != null) return eventId(existing.getPayloadJson());
|
||||
String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
|
||||
WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType,
|
||||
businessNo, amount, remark, idempotencyKey, Instant.now());
|
||||
JsonNode payload = objectMapper.valueToTree(value);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
|
||||
String.valueOf(customerId), businessType, businessNo, aggregateVersion, null,
|
||||
EventIdentifiers.newUuid(), eventKey, null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
|
||||
LastlegEventOutbox row = new LastlegEventOutbox();
|
||||
row.setId(IdWorker.getId()); row.setCustomerId(String.valueOf(customerId));
|
||||
row.setAggregateType(businessType); row.setAggregateId(businessNo);
|
||||
row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion());
|
||||
row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING");
|
||||
row.setRetryCount(0); row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
mapper.insert(row);
|
||||
return envelope.eventId();
|
||||
}
|
||||
|
||||
private String eventId(String json) {
|
||||
try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); }
|
||||
catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); }
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.wallet;
|
||||
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
|
||||
public interface WalletResultHandler {
|
||||
boolean supports(String businessType);
|
||||
void handle(WalletCommandCompletedV1 result);
|
||||
}
|
||||
Reference in New Issue
Block a user