优化
This commit is contained in:
@@ -9,18 +9,20 @@
|
||||
|
||||
## 快速索引
|
||||
|
||||
业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。
|
||||
|
||||
拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
|
||||
|
||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||
|
||||
先沿目标引用追踪;状态、权限、资源归属或接口边界仍不清楚时,用 `rg -n -F '关键词' docs/business-index.md` 定向读取。纯文案、局部重构及已知入口不扩读无关约束。
|
||||
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
|
||||
|
||||
退货预报/签收/库存/合箱/出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程托盘快照:`inboundplan/`。细则按对应词查业务索引。
|
||||
退货链查 `returnreceipt/`;计划、自动托盘、柜级预览及来源托盘查 `inboundplan/`。
|
||||
|
||||
| 功能 | 首个入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
|
||||
| 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
|
||||
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.export;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/exports/v1")
|
||||
public class LastlegExportProviderController {
|
||||
private final InternalServiceTokenGuard guard;
|
||||
private final LastlegExportProviderService service;
|
||||
|
||||
public LastlegExportProviderController(InternalServiceTokenGuard guard, LastlegExportProviderService service) {
|
||||
this.guard = guard;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/{exportType}/batches")
|
||||
public ApiResponse<ExportBatchResponse> batch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable String exportType, @RequestBody ExportBatchRequest request) {
|
||||
guard.verify(token);
|
||||
return ApiResponse.success(service.batch(exportType, request));
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.baiyue.lastleg.export;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
||||
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
|
||||
import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
|
||||
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
|
||||
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteModels;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteService;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsModels;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Validator;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Internal, platform-only batches. Reuses the same filters and business projections as the lists. */
|
||||
@Service
|
||||
public class LastlegExportProviderService {
|
||||
@Resource private LastlegOrderService orders;
|
||||
@Resource private InventoryQueryService inventory;
|
||||
@Resource private OverseasQuoteService quotes;
|
||||
@Resource private MonthlyExchangeRateService rates;
|
||||
@Resource private VehicleApplicationService vehicles;
|
||||
@Resource private FleetQuoteService fleetQuotes;
|
||||
@Resource private TripOperationsService operations;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private Validator validator;
|
||||
@Value("${baiyue.security.internal-service-token}") private String signingKey;
|
||||
|
||||
public ExportBatchResponse batch(String type, ExportBatchRequest request) {
|
||||
if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出");
|
||||
}
|
||||
if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1
|
||||
|| request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效");
|
||||
List<ExportColumn> columns;
|
||||
try { columns = LastlegExportColumns.columns(type); }
|
||||
catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
|
||||
int offset = cursor(request.cursor());
|
||||
if (offset % request.limit() != 0) throw invalid("导出游标无效");
|
||||
String snapshot = signature(type, request);
|
||||
boolean first = request.snapshotToken() == null;
|
||||
if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8),
|
||||
request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配");
|
||||
Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
|
||||
String customer = text(parameters.remove("customerId"));
|
||||
if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效");
|
||||
String relabelNo = text(parameters.remove("relabelNo"));
|
||||
boolean costVisible = Boolean.TRUE.equals(parameters.remove("costVisible"));
|
||||
parameters.put("page", offset / request.limit() + 1);
|
||||
parameters.put("size", request.limit());
|
||||
PageResult<?> page = switch (type) {
|
||||
case "LAST_MILE_ORDER_LIST" -> orders.page(customer, query(parameters, LastlegOrderRequests.Query.class), relabelNo);
|
||||
case "OVERSEAS_INVENTORY_BALANCE" -> inventory.exportBalancePage(customer, query(parameters, InventoryQueryRequests.BalanceQuery.class));
|
||||
case "OVERSEAS_INVENTORY_LEDGER" -> inventory.ledgers(customer, query(parameters, InventoryQueryRequests.LedgerQuery.class));
|
||||
case "OVERSEAS_QUOTE_LIST" -> quotes.page(query(parameters, OverseasQuoteRequests.Query.class));
|
||||
case "MONTHLY_EXCHANGE_RATE_LIST" -> rates.page(query(parameters, MonthlyExchangeRateDtos.Query.class));
|
||||
case "VEHICLE_LIST" -> vehicles.page(query(parameters, VehicleModels.Query.class));
|
||||
case "FLEET_QUOTE_LIST" -> fleetQuotes.page(query(parameters, FleetQuoteModels.Query.class));
|
||||
case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
|
||||
default -> throw invalid("不支持该导出类型");
|
||||
};
|
||||
List<Map<String, Object>> rows = page.records().stream().map(value -> row(value, columns)).toList();
|
||||
int next = offset + rows.size();
|
||||
if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进");
|
||||
return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows,
|
||||
String.valueOf(next), next < page.total(), first ? page.total() : null);
|
||||
}
|
||||
|
||||
private <T> T query(Map<String, Object> parameters, Class<T> type) {
|
||||
Set<String> names = Arrays.stream(type.getRecordComponents()).map(c -> c.getName()).collect(Collectors.toSet());
|
||||
if (!names.containsAll(parameters.keySet())) throw invalid("包含不支持的导出筛选条件");
|
||||
try {
|
||||
T value = json.convertValue(parameters, type);
|
||||
if (!validator.validate(value).isEmpty()) throw invalid("导出筛选条件无效");
|
||||
return value;
|
||||
} catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); }
|
||||
}
|
||||
|
||||
private Map<String, Object> row(Object value, List<ExportColumn> columns) {
|
||||
JsonNode source = json.valueToTree(value);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (ExportColumn column : columns) {
|
||||
JsonNode node = source;
|
||||
for (String part : column.key().split("\\.")) node = node.path(part);
|
||||
Object cell = node.isMissingNode() || node.isNull() ? null
|
||||
: node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText();
|
||||
result.put(column.key(), cell);
|
||||
}
|
||||
if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) {
|
||||
result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否");
|
||||
result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否");
|
||||
result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus()));
|
||||
}
|
||||
if (value instanceof TripOperationsModels.Row trip) {
|
||||
result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / ")));
|
||||
result.put("externalAgreedAmounts", trip.externalAgreedAmounts().stream().map(m -> m.currency() + " " + m.amount().toPlainString()).collect(Collectors.joining(" / ")));
|
||||
result.put("financeStatus", "未查询财务");
|
||||
result.put("costs", trip.costs() == null ? "无成本读取权限" : trip.costs().totals().isEmpty() ? "未登记成本"
|
||||
: trip.costs().totals().stream().map(t -> t.currency() + " " + t.directCost().toPlainString()).collect(Collectors.joining(" / ")));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String signature(String type, ExportBatchRequest r) {
|
||||
try {
|
||||
Map<String, Object> context = new TreeMap<>();
|
||||
context.put("type", type); context.put("task", r.taskId()); context.put("requester", r.requestedBy());
|
||||
context.put("dispatch", r.dispatchVersion()); context.put("limit", r.limit());
|
||||
context.put("parameters", new TreeMap<>(r.parameters() == null ? Map.of() : r.parameters()));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(json.writeValueAsBytes(context)));
|
||||
} catch (Exception e) { throw new IllegalStateException("无法签署导出筛选快照", e); }
|
||||
}
|
||||
|
||||
private String hash(List<ExportColumn> columns) {
|
||||
try { return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(columns))); }
|
||||
catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static int cursor(String value) {
|
||||
if (value == null) return 0;
|
||||
try { int offset = Integer.parseInt(value); if (offset < 0 || offset > 200_000) throw invalid("导出游标无效"); return offset; }
|
||||
catch (NumberFormatException e) { throw invalid("导出游标无效"); }
|
||||
}
|
||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString().trim(); }
|
||||
private static ResponseStatusException invalid(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
|
||||
-2
@@ -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();}
|
||||
}
|
||||
|
||||
-5
@@ -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) {
|
||||
|
||||
-10
@@ -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); }
|
||||
}
|
||||
|
||||
-22
@@ -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))
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+4
@@ -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 != ""'> 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>" +
|
||||
|
||||
+5
-64
@@ -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("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
|
||||
-7
@@ -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()); }
|
||||
}
|
||||
|
||||
-16
@@ -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); }
|
||||
}
|
||||
|
||||
-11
@@ -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());
|
||||
|
||||
-16
@@ -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); }
|
||||
}
|
||||
|
||||
-7
@@ -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());
|
||||
|
||||
-13
@@ -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 } }
|
||||
|
||||
-22
@@ -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() {
|
||||
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
-23
@@ -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() {
|
||||
|
||||
+10
-10
@@ -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());
|
||||
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user