This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
@@ -261,20 +261,6 @@ public class LastlegOrderController {
return ApiResponse.success(null);
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
.body(body);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "将单票尾程订单转入 FBA 目的仓",
@@ -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();
}
}
@@ -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));
}
}
@@ -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); }
}
@@ -61,11 +61,6 @@ public class FleetQuoteService {
for(int i=0;i<request.rows().size();i++)rows.add(createOne(operator,new FleetQuoteModels.Create(request.idempotencyKey()+":"+(i+1),request.rows().get(i)),batchHash));
return new FleetQuoteModels.ImportResult(rows.size(),List.copyOf(rows));
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public byte[] exportCsv(FleetQuoteModels.Query q) {
var csv=new StringBuilder("\uFEFF报价号,用途,往来单位,发货城市,目的仓码,预估距离(mile),币种,整车自提(每单),整车派送(每单),散板派送(每托),生效日,失效日,状态,版本,备注\r\n");
for(var r:list(q)){var d=r.details();csv.append(String.join(",",cell(r.quoteNo()),cell(d.kind()),cell(r.partyName()),cell(d.originCity()),cell(d.destinationCode()),cell(d.distanceMiles()),cell(d.currency()),cell(d.selfPickupPrice()),cell(d.fullDeliveryPrice()),cell(d.looseDeliveryPrice()),cell(d.validFrom()),cell(d.validTo()),cell(r.status()),cell(r.recordVersion()),cell(d.remark()))).append("\r\n");}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
static FleetQuoteModels.Draft normalize(FleetQuoteModels.Draft d) {
if(d.validTo().isBefore(d.validFrom()))throw bad("失效日不得早于生效日");
if(d.selfPickupPrice()==null&&d.fullDeliveryPrice()==null&&d.looseDeliveryPrice()==null)throw bad("至少填写一种报价,留空表示未报价");
@@ -81,7 +76,6 @@ public class FleetQuoteService {
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}}
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}}
private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";}
private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);}
private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);}
}
@@ -35,7 +35,5 @@ public class PlatformFleetQuoteController {
public ApiResponse<FleetQuoteModels.Quote> delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));}
@PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')")
public ApiResponse<FleetQuoteModels.ImportResult> importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));}
@GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')")
public ResponseEntity<byte[]> export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));}
private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();}
}
@@ -17,11 +17,6 @@ public class PlatformTripOperationsController {
public PlatformTripOperationsController(TripOperationsService service,TripOperationsFinanceService finance) { this.service=service;this.finance=finance; }
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<TripOperationsModels.Result> report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); }
@GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')")
public ResponseEntity<byte[]> export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) {
return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user)));
}
@GetMapping("/{tripNo}/finance")
@PreAuthorize("hasAuthority('platform:fleet:operations:list') and hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<TripOperationsFinanceService.Result> finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) {
@@ -120,17 +120,7 @@ public class TripOperationsService {
return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values()));
}
private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);}
public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) {
var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n");
for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()),
cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()),
cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))),
cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));}
private static List<TripOperationsModels.Money> amounts(Map<String,BigDecimal> money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); }
private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); }
static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -87,28 +87,6 @@ public class InventoryQueryController {
return ApiResponse.success(service.ledgers(platformScope(user, customerId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
String scope = platformScope(user, customerId);
return csv(service.exportBalances(scope, query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
String scope = platformScope(user, customerId);
return csv(service.exportLedgers(scope, query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] content, String filename) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
@@ -50,7 +50,8 @@ public final class InventoryQueryRequests {
String skuCode,
String warehouseLocationCode,
LocalDateTime occurredFrom,
LocalDateTime occurredTo
LocalDateTime occurredTo,
String customerName
) {
public LedgerQuery {
if (page == null || page == 0) page = 1L;
@@ -91,6 +91,9 @@ public interface InventoryQueryMapper {
long countBalances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
long countSourceBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> sourceBalances(@Param("customerId") String customerId,
@@ -148,6 +151,7 @@ public interface InventoryQueryMapper {
InventoryBalance visibleBalance(@Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE 1=1" +
"<if test='q.customerName != null and q.customerName != &quot;&quot;'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
@@ -89,65 +89,12 @@ public class InventoryQueryService {
return page(records, query.page(), query.size(), total);
}
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
writeBalancesCsv(customerId, query, output);
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
}
public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output)
throws IOException {
public PageResult<InventoryBalanceView> exportBalancePage(String customerId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.sourceBalances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
writeLedgersCsv(customerId, query, output);
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
}
public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output)
throws IOException {
validateDateRange(query);
output.write(("\uFEFFoccurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryLedgerView> batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList();
if (batch.isEmpty()) break;
for (InventoryLedgerView v : batch) output.write((
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
long total = mapper.countSourceBalances(customerId, query);
var rows = mapper.sourceBalances(customerId, query, offset(query.page(), query.size()), query.size())
.stream().map(this::balanceView).toList();
return page(rows, query.page(), query.size(), total);
}
private InventoryBalanceView balanceView(InventoryBalance value) {
@@ -220,10 +167,4 @@ public class InventoryQueryService {
return new PageResult<>(records, page, size, total, pages);
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text;
return '"' + text.replace("\"", "\"\"") + '"';
}
}
@@ -47,12 +47,5 @@ public class PlatformMonthlyExchangeRateController {
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(month, operator(user), request));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
.body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -99,21 +99,6 @@ public class MonthlyExchangeRateService {
return detail(current.getRateMonth());
}
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
validateRange(query.monthFrom(), query.monthTo());
String status = optionalStatus(query.status());
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
.eq(status != null, MonthlyExchangeRate::getStatus, status)
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
String beforeStatus, String afterStatus, String operator, String remark) {
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
@@ -150,7 +135,6 @@ public class MonthlyExchangeRateService {
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -92,17 +92,6 @@ public class PlatformOverseasQuoteController {
return ApiResponse.success(service.changeStatus(id, operator(user), request));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')")
@Operation(summary = "Export filtered overseas warehouse quotes",
description = "Required authority: platform:overseas-master-data:quote:export")
public ResponseEntity<byte[]> export(@Valid OverseasQuoteRequests.Query query) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv")
.body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
@@ -138,21 +138,6 @@ public class OverseasQuoteService {
return detail(id);
}
public byte[] exportCsv(OverseasQuoteRequests.Query query) {
OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(),
query.warehouseId(), query.providerName(), query.currency(), query.status());
StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n");
for (var row : page(exportQuery).records()) {
csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',')
.append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',')
.append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',')
.append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',')
.append(cell(row.versionCode())).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void replaceChildren(long quoteId, List<OverseasQuoteRequests.Fee> feeRows,
List<OverseasQuoteRequests.TransitPrice> transitRows) {
fees.delete(new LambdaQueryWrapper<OverseasQuoteFee>().eq(OverseasQuoteFee::getQuoteId, quoteId));
@@ -221,7 +206,6 @@ public class OverseasQuoteService {
private static long version(Long value) { return value == null ? 0L : value; }
private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -76,13 +76,6 @@ public class PlatformVehicleController {
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
@@ -100,18 +100,6 @@ public class VehicleApplicationService {
return detail(id);
}
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n");
for (var row : rows) csv.append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
@@ -169,7 +157,6 @@ public class VehicleApplicationService {
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
@@ -522,49 +522,6 @@ public class LastlegOrderService {
return orders.size();
}
public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
writeExportCsv(customerId, query, output);
return output.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("Unable to export tail-leg orders", e);
}
}
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
Page<LastlegOrder> result = orderMapper.selectPage(new Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
List<LastlegOrder> orders = result.getRecords();
if (orders.isEmpty()) break;
Map<String, String> containerNodes = containerNodes(orders);
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(displayInboundCode(order)),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(yesOrNo(order.getAbnormal())), csv(yesOrNo(order.getDetained())),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()),
csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())),
csv(order.getPalletMergeFlag()), csv(LastlegOrderStatus.labelOf(order.getCurrentStatus())), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
}
output.flush();
// The pagination interceptor may cap the requested batch size.
if (!result.hasNext()) break;
page++;
}
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
return filter(customerId, query, null);
}
@@ -719,11 +676,6 @@ public class LastlegOrderService {
? "COMPLETED" : "PENDING_PROCESSING";
}
private static String yesOrNo(Boolean value) {
if (value == null) return null;
return value ? "是" : "否";
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
@@ -735,10 +687,4 @@ public class LastlegOrderService {
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text;
return '"' + text.replace("\"", "\"\"") + '"';
}
}
@@ -175,108 +175,6 @@
}
}
},
"/api/platform/v1/last-leg/fleet/operations/export": {
"get": {
"description": "platform:fleet:operations:export;同筛选同事实来源的CSV",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 200
}
},
{
"name": "from",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "to",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "warehouseId",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "sourceType",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"OWN",
"EXTERNAL"
]
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"IN_TRANSIT",
"COMPLETED"
]
}
}
],
"responses": {
"200": {
"description": "UTF-8 BOM CSV",
"content": {
"text/csv": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/record": {
"parameters": [
{
@@ -600,89 +600,6 @@
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/export": {
"get": {
"description": "platform:fleet:quote:export;全筛选CSV,最多5000行,公式注入转义,空报价保留空值,币种不混合",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 20
}
},
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"PURCHASE",
"SALE"
]
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE"
]
}
},
{
"name": "effectiveOn",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date"
}
}
],
"responses": {
"200": {
"description": "UTF-8 BOM CSV",
"content": {
"text/csv": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
},
"components": {
@@ -0,0 +1,51 @@
openapi: 3.0.3
info:
title: Lastleg download-center export provider
version: 1.0.0
description: |
Admin 导出统一通过 POST /api/platform/v1/files/export-tasks 创建 XLSX 任务。
File 按导出类型校验原业务 export 权限,通过内部令牌分批读取 Lastleg;Provider 不经公网 Gateway。
parameters 与对应业务列表筛选一致,不包含 page/size;进销存支持 customerName。
尾程订单支持 relabelNo 和 sailingStage,库存余额按来源入库单展开,不导出内部数据库 ID。
每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。
snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。
TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。
原 Lastleg 业务 /export CSV 公网接口已移除。文件领取、过期、失败重试由 File 下载中心管理。
paths:
/internal/exports/v1/{exportType}/batches:
post:
summary: 读取一批平台导出业务数据
parameters:
- in: header
name: X-Internal-Service-Token
required: true
schema: { type: string }
- in: path
name: exportType
required: true
schema:
type: string
enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, limit]
properties:
taskId: { type: string }
tenantId: { type: string, enum: ['0'] }
appCode: { type: string, enum: [PLATFORM] }
requestedBy: { type: string }
dispatchVersion: { type: integer, minimum: 1 }
parameters: { type: object, additionalProperties: true }
cursor: { type: string, nullable: true }
snapshotToken: { type: string, nullable: true }
limit: { type: integer, minimum: 1, maximum: 200 }
responses:
'200':
description: ApiResponse 包装 ExportBatchResponse;首批返回 columns/totalRows,后续批次返回同一 schemaHash/snapshotToken 与递增游标。
'400': { description: 类型、参数、游标或筛选快照无效 }
'401': { description: 内部令牌无效 }
'403': { description: 非平台作用域 }
@@ -339,14 +339,6 @@ paths:
orderIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } }
palletMergeRequired: { type: boolean }
responses: { '200': { description: 批量合托标记已更新 } }
/api/platform/v1/last-leg/orders/export:
get:
summary: 导出筛选后的尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。
security: [{ bearerAuth: [] }]
parameters:
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/orders/container-summaries:
get:
summary: 分页查询柜级入库计划进度
@@ -44,17 +44,6 @@ paths:
data:
type: array
items: { $ref: '#/components/schemas/PartnerWarehouseOption' }
/api/platform/v1/last-leg/master-data/overseas-quotes/export:
get:
summary: 导出海外仓报价
description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV'
parameters:
- { name: keyword, in: query, schema: { type: string } }
- { name: warehouseId, in: query, schema: { type: string } }
- { name: providerName, in: query, schema: { type: string } }
- { name: currency, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/overseas-quotes/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -96,15 +85,6 @@ paths:
summary: 查询最新有效月度汇率摘要
description: '权限:platform:overseas-master-data:exchange-rate:list'
responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } }
/api/platform/v1/last-leg/master-data/exchange-rates/export:
get:
summary: 导出筛选后的全部月度汇率
description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV'
parameters:
- { name: monthFrom, in: query, schema: { type: string } }
- { name: monthTo, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/exchange-rates/{month}:
parameters:
- { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } }
@@ -142,16 +122,6 @@ paths:
description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } }
responses: { '200': { description: 业务响应,data为Vehicle } }
/api/platform/v1/last-leg/master-data/vehicles/export:
get:
summary: 导出筛选后的全部车辆主数据
description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV'
parameters:
- { name: keyword, in: query, schema: { type: string } }
- { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } }
- { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/vehicles/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -123,31 +123,9 @@ class LastlegOrderControllerTest {
verify(service).containerProgressDetail("BOOKING-1");
}
@Test
void exportBuildsCsvBeforeReturningTheResponse() {
byte[] csv = "csv".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(service.exportCsv("200", query)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), "200", query);
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv("200", query);
}
@Test
void platformOperatorCanExportAllCustomersWithoutFilters() {
byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8);
LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
when(service.exportCsv(null, emptyQuery)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery);
assertEquals(Long.valueOf(1), emptyQuery.page());
assertEquals(Long.valueOf(20), emptyQuery.size());
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv(isNull(), eq(emptyQuery));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.export;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
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.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
import com.baiyue.lastleg.fleet.reporting.TripOperationsModels;
import com.baiyue.lastleg.fleet.reporting.TripOperationsService;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import jakarta.validation.Validation;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class LastlegExportProviderServiceTest {
private LastlegExportProviderService service;
private LastlegOrderService orders;
private InventoryQueryService inventory;
private MonthlyExchangeRateService rates;
private TripOperationsService operations;
private jakarta.validation.ValidatorFactory validators;
@BeforeEach void setup() {
service = new LastlegExportProviderService();
orders = mock(LastlegOrderService.class); inventory = mock(InventoryQueryService.class);
rates = mock(MonthlyExchangeRateService.class); operations = mock(TripOperationsService.class);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "rates", rates);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules().disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS));
validators = Validation.buildDefaultValidatorFactory();
ReflectionTestUtils.setField(service, "validator", validators.getValidator());
ReflectionTestUtils.setField(service, "signingKey", "test-internal-token-with-at-least-32-bytes");
}
@AfterEach void close() { validators.close(); }
@Test void batchesAllRowsAndBindsSnapshotToFiltersRequesterAndAttempt() {
var row = new MonthlyExchangeRateDtos.Response("2026-10", "USD", "CNY", new BigDecimal("7.12"), "ACTIVE", 1, "operator", LocalDateTime.of(2026,10,8,12,0), null);
when(rates.page(any())).thenAnswer(call -> {
MonthlyExchangeRateDtos.Query q = call.getArgument(0);
return new PageResult<>(List.of(row), q.page(), q.size(), 2, 2);
});
var first = service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "ACTIVE"), null, null));
assertTrue(first.hasMore()); assertEquals(2L, first.totalRows());
assertEquals(new BigDecimal("7.12"), first.rows().get(0).get("usdToCnyAverage"));
assertEquals("2026-10-08T12:00:00", first.rows().get(0).get("updatedAt"));
var second = service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "ACTIVE"), first.nextCursor(), first.snapshotToken()));
assertFalse(second.hasMore()); assertTrue(second.columns().isEmpty()); assertEquals(first.schemaHash(), second.schemaHash());
verify(rates).page(new MonthlyExchangeRateDtos.Query(2,1,null,null,"ACTIVE"));
assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", request(Map.of("status", "INACTIVE"), "1", first.snapshotToken())));
assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", new ExportBatchRequest("t","0","PLATFORM","other",1,Map.of("status","ACTIVE"),"1",first.snapshotToken(),1)));
assertThrows(ResponseStatusException.class, () -> service.batch("MONTHLY_EXCHANGE_RATE_LIST", new ExportBatchRequest("t","0","PLATFORM","u",2,Map.of("status","ACTIVE"),"1",first.snapshotToken(),1)));
}
@Test void retainsOrderStageFalseAndRelabelFiltersWithoutExportingInternalIds() {
var order = new ObjectMapper().findAndRegisterModules().convertValue(Map.of("id", "9007199254740993", "orderNo", "LL-1", "currentStatus", "PENDING_ASSIGNMENT", "abnormal", false, "detained", true), com.baiyue.lastleg.dto.LastlegOrderSummary.class);
when(orders.page(isNull(), any(), eq("RL-1"))).thenReturn(new PageResult<>(List.of(order),1,1,1,1));
var result = service.batch("LAST_MILE_ORDER_LIST", request(Map.of("detained",false,"sailingStage","BEFORE_DEPARTURE","relabelNo","RL-1"),null,null));
verify(orders).page(isNull(), argThat(q -> Boolean.FALSE.equals(q.detained()) && q.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE), eq("RL-1"));
assertEquals("否", result.rows().get(0).get("abnormal"));
assertEquals("是", result.rows().get(0).get("detained"));
assertEquals("待分配", result.rows().get(0).get("currentStatus"));
assertTrue(result.columns().stream().noneMatch(c -> c.key().equals("id") || c.key().equals("customerId")));
}
@Test void keepsCustomerScopeAndLedgerCustomerFilter() {
when(inventory.ledgers(eq("200"), any())).thenReturn(new PageResult<>(List.of(),1,1,0,0));
service.batch("OVERSEAS_INVENTORY_LEDGER", request(Map.of("customerId","200","customerName","客户"),null,null));
verify(inventory).ledgers(eq("200"), argThat(q -> "客户".equals(q.customerName())));
}
@Test void defaultsCostVisibilityClosed() {
when(operations.report(any(), eq(false))).thenReturn(new TripOperationsModels.Result(new PageResult<>(List.of(),1,1,0,0),null,null));
service.batch("TRIP_OPERATIONS_LIST",request(Map.of("from","2026-10-01T00:00:00Z","to","2026-10-08T00:00:00Z"),null,null));
verify(operations).report(any(), eq(false));
}
@Test void rejectsScopeInvalidParametersAndUntrustedInternalCalls() {
assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",new ExportBatchRequest("t","1","MERCHANT","u",1,Map.of(),null,null,1)));
assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",request(Map.of("unknown","x"),null,null)));
assertThrows(ResponseStatusException.class, () -> service.batch("LAST_MILE_ORDER_LIST",request(Map.of(),"1",null)));
assertThrows(ResponseStatusException.class, () -> new LastlegExportProviderController(new InternalServiceTokenGuard("expected"),service).batch("wrong","LAST_MILE_ORDER_LIST",request(Map.of(),null,null)));
verifyNoInteractions(orders);
}
private ExportBatchRequest request(Map<String,Object> p,String cursor,String snapshot) {
return new ExportBatchRequest("t","0","PLATFORM","u",1,p,cursor,snapshot,1);
}
}
@@ -59,9 +59,9 @@ class FleetQuotePersistenceTest {
service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row)));
assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价");
}
@Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() {
@Test void deletePreservesHistoryAndBusinessText() {
var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO)));
assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2");
assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).records().get(0).details().originCity()).isEqualTo("=2+2");
service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero();
assertThat(service.history(created.quoteNo())).hasSize(2);
}
@@ -60,32 +60,9 @@ class InventoryQueryControllerTest {
verify(service).dashboard(eq(null), eq(query));
}
@Test
void balanceExportIsMaterializedBeforeReturningResponse() {
byte[] csv = "\uFEFFcustomerId\r\n10001\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(service.exportBalances("10001", query)).thenReturn(csv);
ResponseEntity<byte[]> response = controller.exportBalances(user(0L, "PLATFORM"), "10001", query);
org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody());
org.junit.jupiter.api.Assertions.assertEquals("text/csv;charset=UTF-8",
response.getHeaders().getContentType().toString());
verify(service).exportBalances("10001", query);
}
@Test
void ledgerExportIsMaterializedBeforeReturningResponse() {
InventoryQueryRequests.LedgerQuery ledgerQuery = new InventoryQueryRequests.LedgerQuery(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
byte[] csv = "\uFEFFoccurredAt\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(service.exportLedgers("10001", ledgerQuery)).thenReturn(csv);
ResponseEntity<byte[]> response = controller.exportLedgers(
user(0L, "PLATFORM"), "10001", ledgerQuery);
org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody());
verify(service).exportLedgers("10001", ledgerQuery);
}
@Test
void unauthenticatedPositionDetailIsRejected() {
@@ -198,21 +198,21 @@ class InventoryQueryServiceTest {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null,
java.time.LocalDateTime.of(2026, 8, 18, 0, 0),
java.time.LocalDateTime.of(2026, 8, 17, 0, 0));
java.time.LocalDateTime.of(2026, 8, 17, 0, 0), null);
assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query));
verify(mapper, never()).countLedgers(any(), any());
}
@Test
void balanceExportUsesInboundOrderPackageColumnsOnly() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
when(mapper.sourceBalances("100", query, 0, 10_000)).thenReturn(List.of());
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages"));
assertFalse(csv.contains("Quantity"));
void balanceExportPagesSourceRowsInsteadOfGroupedBalances() {
var query = balanceQuery(null);
when(mapper.countSourceBalances("100", query)).thenReturn(201L);
when(mapper.sourceBalances("100", query, 0, 20)).thenReturn(List.of());
var page = service.exportBalancePage("100", query);
assertEquals(201L, page.total());
verify(mapper).sourceBalances("100", query, 0, 20);
verify(mapper, never()).countBalances(any(), any());
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
@@ -224,7 +224,7 @@ class InventoryQueryServiceTest {
InventoryQueryRequests.BalanceQuery balances = new InventoryQueryRequests.BalanceQuery(
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
InventoryQueryRequests.LedgerQuery ledgers = new InventoryQueryRequests.LedgerQuery(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertEquals(1L, balances.page());
assertEquals(20L, balances.size());
@@ -27,7 +27,7 @@ class PlatformOverseasQuoteControllerContractTest {
assertThat(authorities.get("create")).contains("platform:overseas-master-data:quote:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export");
assertThat(authorities).doesNotContainKey("export");
Method options = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("warehouseOptions"))
@@ -23,6 +23,6 @@ class PlatformVehicleControllerContractTest {
assertThat(authorities.get("create")).contains("platform:overseas-master-data:vehicle:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:vehicle:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:vehicle:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:vehicle:export");
assertThat(authorities).doesNotContainKey("export");
}
}
@@ -63,12 +63,7 @@ class VehicleApplicationServiceTest {
org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()).create(org.mockito.ArgumentMatchers.any());
}
@Test void exportUsesPlateAsBusinessIdentifierAndIncludesAllCapacityDimensions() {
when(repository.list(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(vehicle(3, 1000)));
String csv = new String(service.exportCsv(new VehicleModels.Query(1, 20, null, null, null, null)), java.nio.charset.StandardCharsets.UTF_8);
assertThat(csv).contains("最大托位,最大载重(kg),可用容积(m³)").contains("\"30\",\"18000\",\"90\"")
.doesNotContain("车辆ID");
}
private static VehicleModels.Update update(long version, long mileage) {
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
@@ -389,51 +389,9 @@ class LastlegOrderServiceTest {
assertEquals(25, page.getValue().getSize());
}
@Test
void exportUsesChineseBooleanAndMainStatusLabels() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setAbnormal(false);
order.setDetained(true);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 1000);
resultPage.setRecords(List.of(order));
resultPage.setTotal(1);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null)), StandardCharsets.UTF_8);
assertTrue(csv.contains("\"否\",\"是\""));
assertTrue(csv.contains("\"待分配\""));
assertFalse(csv.contains("\"false\""));
assertFalse(csv.contains("\"PENDING_ASSIGNMENT\""));
assertEquals("草稿", LastlegOrderStatus.labelOf("DRAFT"));
assertEquals("审核通过", LastlegOrderStatus.labelOf("APPROVED"));
assertEquals("已分配目的仓", LastlegOrderStatus.labelOf("DESTINATION_ASSIGNED"));
assertEquals("已生成入库计划", LastlegOrderStatus.labelOf("INBOUND_PLANNED"));
assertEquals("处理中", LastlegOrderStatus.labelOf("PROCESSING"));
}
@Test
void exportContinuesAfterThePaginationInterceptorCapsTheBatchSize() {
when(orders.selectPage(any(), any())).thenAnswer(invocation -> {
com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder> page = invocation.getArgument(0);
page.setSize(500);
page.setTotal(501);
long start = (page.getCurrent() - 1) * page.getSize() + 1;
page.setRecords(java.util.stream.LongStream.rangeClosed(start, Math.min(start + 499, 501))
.mapToObj(id -> order(id, "100", "PENDING_ASSIGNMENT", null)).toList());
return page;
});
var query = new LastlegOrderRequests.Query(1L, 20L, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE);
String csv = new String(service.exportCsv("100", query), StandardCharsets.UTF_8);
assertEquals(502, csv.lines().count());
assertTrue(csv.contains("\"LM-501\""));
verify(orders, org.mockito.Mockito.times(2)).selectPage(any(), any());
}
@Test
void pageWarehouseFilterMatchesFrozenWarehouseName() {