优化
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.export;
|
||||
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Explicit business columns; internal database identifiers are never exported. */
|
||||
final class LastlegExportColumns {
|
||||
private LastlegExportColumns() { }
|
||||
private static final Map<String, String> DEFINITIONS = Map.ofEntries(
|
||||
Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
|
||||
Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"),
|
||||
Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"),
|
||||
Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"),
|
||||
Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "month:月份,baseCurrency:基准币种,quoteCurrency:报价币种,usdToCnyAverage:月平均汇率:DECIMAL,status:状态,updatedAt:更新时间:DATETIME"),
|
||||
Map.entry("VEHICLE_LIST", "plateNumber:车牌号,model:车型,fuelConsumptionPer100Km:油耗(L/100km):DECIMAL,mileageKm:当前公里码(km):DECIMAL,driverOrDispatchCompany:司机或外派单位,phone:联系电话,region:区域,ownership:车辆归属,status:状态,maxPalletSpaces:最大托位:DECIMAL,maxLoadKg:最大载重(kg):DECIMAL,maxVolumeCbm:可用容积(m³):DECIMAL,updatedAt:更新时间:DATETIME"),
|
||||
Map.entry("FLEET_QUOTE_LIST", "quoteNo:报价号,details.kind:用途,partyName:往来单位,details.originCity:发货城市,details.destinationCode:目的仓码,details.distanceMiles:预估距离(mile):DECIMAL,details.currency:币种,details.selfPickupPrice:整车自提(每单):DECIMAL,details.fullDeliveryPrice:整车派送(每单):DECIMAL,details.looseDeliveryPrice:散板派送(每托):DECIMAL,details.validFrom:生效日:DATE,details.validTo:失效日:DATE,status:状态,recordVersion:版本:INTEGER,details.remark:备注"),
|
||||
Map.entry("TRIP_OPERATIONS_LIST", "tripNo:车次号,status:状态,vehiclePlate:车牌,driverName:司机,warehouseName:起运仓,departedAt:实际发车UTC,completedAt:货物结案UTC,destinations:目的地,physicalPallets:实物托盘数:INTEGER,palletSpaces:标准托位:INTEGER,ownPackages:自有箱数:INTEGER,externalPackages:外接箱数:INTEGER,externalAgreedAmounts:外接约定金额(按币种),measured.actualDistanceMiles:实际里程(mile):DECIMAL,measured.actualFuelLitres:实际油耗(L):DECIMAL,measured.remark:实测备注,financeStatus:应收与实收,costs:直接成本"));
|
||||
static List<ExportColumn> columns(String type) {
|
||||
String definition = DEFINITIONS.get(type);
|
||||
if (definition == null) throw new IllegalArgumentException("Unsupported Lastleg export type");
|
||||
return Arrays.stream(definition.split(",")).map(value -> {
|
||||
String[] parts = value.split(":");
|
||||
return new ExportColumn(parts[0], parts[1],
|
||||
parts.length == 3 ? ExportColumnType.valueOf(parts[2]) : ExportColumnType.STRING, 24);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.export;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/exports/v1")
|
||||
public class LastlegExportProviderController {
|
||||
private final InternalServiceTokenGuard guard;
|
||||
private final LastlegExportProviderService service;
|
||||
|
||||
public LastlegExportProviderController(InternalServiceTokenGuard guard, LastlegExportProviderService service) {
|
||||
this.guard = guard;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/{exportType}/batches")
|
||||
public ApiResponse<ExportBatchResponse> batch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable String exportType, @RequestBody ExportBatchRequest request) {
|
||||
guard.verify(token);
|
||||
return ApiResponse.success(service.batch(exportType, request));
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.baiyue.lastleg.export;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteModels;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteService;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsModels;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Validator;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Internal, platform-only batches. Reuses the same filters and business projections as the lists. */
|
||||
@Service
|
||||
public class LastlegExportProviderService {
|
||||
@Resource private LastlegOrderService orders;
|
||||
@Resource private InventoryQueryService inventory;
|
||||
@Resource private OverseasQuoteService quotes;
|
||||
@Resource private MonthlyExchangeRateService rates;
|
||||
@Resource private VehicleApplicationService vehicles;
|
||||
@Resource private FleetQuoteService fleetQuotes;
|
||||
@Resource private TripOperationsService operations;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private Validator validator;
|
||||
@Value("${baiyue.security.internal-service-token}") private String signingKey;
|
||||
|
||||
public ExportBatchResponse batch(String type, ExportBatchRequest request) {
|
||||
if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出");
|
||||
}
|
||||
if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1
|
||||
|| request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效");
|
||||
List<ExportColumn> columns;
|
||||
try { columns = LastlegExportColumns.columns(type); }
|
||||
catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
|
||||
int offset = cursor(request.cursor());
|
||||
if (offset % request.limit() != 0) throw invalid("导出游标无效");
|
||||
String snapshot = signature(type, request);
|
||||
boolean first = request.snapshotToken() == null;
|
||||
if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8),
|
||||
request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配");
|
||||
Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
|
||||
String customer = text(parameters.remove("customerId"));
|
||||
if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效");
|
||||
String relabelNo = text(parameters.remove("relabelNo"));
|
||||
boolean costVisible = Boolean.TRUE.equals(parameters.remove("costVisible"));
|
||||
parameters.put("page", offset / request.limit() + 1);
|
||||
parameters.put("size", request.limit());
|
||||
PageResult<?> page = switch (type) {
|
||||
case "LAST_MILE_ORDER_LIST" -> orders.page(customer, query(parameters, LastlegOrderRequests.Query.class), relabelNo);
|
||||
case "OVERSEAS_INVENTORY_BALANCE" -> inventory.exportBalancePage(customer, query(parameters, InventoryQueryRequests.BalanceQuery.class));
|
||||
case "OVERSEAS_INVENTORY_LEDGER" -> inventory.ledgers(customer, query(parameters, InventoryQueryRequests.LedgerQuery.class));
|
||||
case "OVERSEAS_QUOTE_LIST" -> quotes.page(query(parameters, OverseasQuoteRequests.Query.class));
|
||||
case "MONTHLY_EXCHANGE_RATE_LIST" -> rates.page(query(parameters, MonthlyExchangeRateDtos.Query.class));
|
||||
case "VEHICLE_LIST" -> vehicles.page(query(parameters, VehicleModels.Query.class));
|
||||
case "FLEET_QUOTE_LIST" -> fleetQuotes.page(query(parameters, FleetQuoteModels.Query.class));
|
||||
case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
|
||||
default -> throw invalid("不支持该导出类型");
|
||||
};
|
||||
List<Map<String, Object>> rows = page.records().stream().map(value -> row(value, columns)).toList();
|
||||
int next = offset + rows.size();
|
||||
if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进");
|
||||
return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows,
|
||||
String.valueOf(next), next < page.total(), first ? page.total() : null);
|
||||
}
|
||||
|
||||
private <T> T query(Map<String, Object> parameters, Class<T> type) {
|
||||
Set<String> names = Arrays.stream(type.getRecordComponents()).map(c -> c.getName()).collect(Collectors.toSet());
|
||||
if (!names.containsAll(parameters.keySet())) throw invalid("包含不支持的导出筛选条件");
|
||||
try {
|
||||
T value = json.convertValue(parameters, type);
|
||||
if (!validator.validate(value).isEmpty()) throw invalid("导出筛选条件无效");
|
||||
return value;
|
||||
} catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); }
|
||||
}
|
||||
|
||||
private Map<String, Object> row(Object value, List<ExportColumn> columns) {
|
||||
JsonNode source = json.valueToTree(value);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (ExportColumn column : columns) {
|
||||
JsonNode node = source;
|
||||
for (String part : column.key().split("\\.")) node = node.path(part);
|
||||
Object cell = node.isMissingNode() || node.isNull() ? null
|
||||
: node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText();
|
||||
result.put(column.key(), cell);
|
||||
}
|
||||
if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) {
|
||||
result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否");
|
||||
result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否");
|
||||
result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus()));
|
||||
}
|
||||
if (value instanceof TripOperationsModels.Row trip) {
|
||||
result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / ")));
|
||||
result.put("externalAgreedAmounts", trip.externalAgreedAmounts().stream().map(m -> m.currency() + " " + m.amount().toPlainString()).collect(Collectors.joining(" / ")));
|
||||
result.put("financeStatus", "未查询财务");
|
||||
result.put("costs", trip.costs() == null ? "无成本读取权限" : trip.costs().totals().isEmpty() ? "未登记成本"
|
||||
: trip.costs().totals().stream().map(t -> t.currency() + " " + t.directCost().toPlainString()).collect(Collectors.joining(" / ")));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String signature(String type, ExportBatchRequest r) {
|
||||
try {
|
||||
Map<String, Object> context = new TreeMap<>();
|
||||
context.put("type", type); context.put("task", r.taskId()); context.put("requester", r.requestedBy());
|
||||
context.put("dispatch", r.dispatchVersion()); context.put("limit", r.limit());
|
||||
context.put("parameters", new TreeMap<>(r.parameters() == null ? Map.of() : r.parameters()));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(json.writeValueAsBytes(context)));
|
||||
} catch (Exception e) { throw new IllegalStateException("无法签署导出筛选快照", e); }
|
||||
}
|
||||
|
||||
private String hash(List<ExportColumn> columns) {
|
||||
try { return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(columns))); }
|
||||
catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static int cursor(String value) {
|
||||
if (value == null) return 0;
|
||||
try { int offset = Integer.parseInt(value); if (offset < 0 || offset > 200_000) throw invalid("导出游标无效"); return offset; }
|
||||
catch (NumberFormatException e) { throw invalid("导出游标无效"); }
|
||||
}
|
||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString().trim(); }
|
||||
private static ResponseStatusException invalid(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
}
|
||||
Reference in New Issue
Block a user