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("\"", "\"\"") + '"';
}
}