package com.baiyue.lastleg.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.model.LastlegOrderType; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @Service public class LastlegOrderService { public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; public static final String PALLET_MERGED = "MERGED"; @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private LastlegOrderFeeMapper feeMapper; @Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private InboundPlanCartonMapper cartonMapper; @Resource private FbaWarehouseValidator fbaWarehouseValidator; @Resource private InventoryCommandService inventory; @Resource private InboundOrderDetainedOutboxService detainedOutbox; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); Map> mergeRelations = palletMergeRelations(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @Transactional(readOnly = true) public PageResult containerProgressPage( LastlegOrderRequests.ContainerProgressQuery query) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } long total = orderMapper.countContainerProgress(query); List records = total == 0 ? List.of() : orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size()) .stream().map(this::requireFrozenContainer).toList(); long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); return new PageResult<>(records, query.page(), query.size(), total, pages); } @Transactional(readOnly = true) public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) { String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim(); if (!StringUtils.hasText(bookingId)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); } ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId); if (summary == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); } return new ContainerPlanProgressDetail(requireFrozenContainer(summary), orderMapper.findContainerProgressItems(bookingId)); } private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) { try { ContainerNumbers.requireCanonical(summary.containerNo()); return summary; } catch (IllegalArgumentException exception) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜真实集装箱号缺失、格式无效或存在漂移", exception); } } public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); List history = historyMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); List fees = feeMapper.selectList(new LambdaQueryWrapper() .eq(LastlegOrderFee::getCustomerId, order.getCustomerId()) .eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt)); List operations = operationLogMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList()); } @Transactional public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, String operatorName) { LastlegOrder order = findOwned(customerId, orderId); LocalDateTime now = LocalDateTime.now(); LastlegOrderFee fee = new LastlegOrderFee(); fee.setId(IdWorker.getId()); fee.setCustomerId(order.getCustomerId()); fee.setOrderId(orderId); fee.setFeeItem(request.feeItem().trim()); fee.setCurrency(request.currency()); fee.setAmount(request.amount()); fee.setStatus(request.status()); fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription()) ? request.supplierDescription().trim() : null); fee.setCreatedBy(operatorName); fee.setCreatedAt(now); fee.setUpdatedAt(now); feeMapper.insert(fee); LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(orderId); operation.setOperationNode("新增费用"); operation.setOperatorName(operatorName); operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString()); operation.setCreatedAt(now); operationLogMapper.insert(operation); return fee; } @Transactional public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, String operatorName) { setDetained(customerId, orderId, request, null, operatorName); } @Transactional public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, String operatorId, String operatorName) { LastlegOrder current = findOwned(customerId, orderId); LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); boolean detained = request.detained(); if (Boolean.TRUE.equals(order.getDetained()) == detained) { throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held"); } if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held"); } LocalDateTime now = LocalDateTime.now(); order.setDetained(detained); order.setUpdatedAt(now); orderMapper.updateById(order); if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim()); } LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(orderId); operation.setOperationNode(detained ? "暂扣" : "解除暂扣"); operation.setOperatorName(operatorName); operation.setResult(request.remark().trim()); operation.setCreatedAt(now); operationLogMapper.insert(operation); if (StringUtils.hasText(order.getSourceInboundId())) { detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName, now.toInstant(ZoneOffset.UTC), operation.getId()); } } @Transactional public void setPalletMergeRequired(String customerId, long orderId, LastlegOrderRequests.SetPalletMergeRequired request, String operatorName) { LastlegOrder current = findOwned(customerId, orderId); LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot change the pallet merge mark"); } if (PALLET_MERGED.equals(order.getPalletMergeFlag())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "A completed pallet merge cannot be unmarked"); } String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; if (Objects.equals(target, order.getPalletMergeFlag())) return; LocalDateTime now = LocalDateTime.now(); if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated"); } LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(orderId); operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记"); operation.setOperatorName(operatorName); operation.setResult(request.palletMergeRequired() ? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示"); operation.setCreatedAt(now); operationLogMapper.insert(operation); } @Transactional public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request, String operatorName) { List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); List orders = orderMapper.selectList(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .in(LastlegOrder::getId, ids).last("FOR UPDATE")); if (orders.size() != ids.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); } if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer"); } if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode()) ? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse"); } if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus()) || PALLET_MERGED.equals(order.getPalletMergeFlag()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed"); } String batchId = request.palletMergeRequired() ? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null; String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; LocalDateTime now = LocalDateTime.now(); for (LastlegOrder order : orders) { if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated"); } LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(order.getId()); operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记"); operation.setOperatorName(operatorName); operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组"); operation.setCreatedAt(now); operationLogMapper.insert(operation); } } @Transactional public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); } FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required( request.warehouseId(), request.warehouseCode(), request.warehouseName()); String warehouseId = Long.toString(target.id()); List orders = orderMapper.selectList(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .in(LastlegOrder::getId, ids).last("FOR UPDATE")); if (orders.size() != ids.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); } for (LastlegOrder order : orders) { ensureTransferable(order); } LocalDateTime now = LocalDateTime.now(); for (LastlegOrder order : orders) { String fromStatus = order.getCurrentStatus(); order.setTransferWarehouseId(warehouseId); order.setTransferWarehouseCode(target.code()); order.setTransferWarehouseName(target.name()); order.setCurrentStatus(DESTINATION_ASSIGNED); order.setUpdatedAt(now); orderMapper.updateById(order); LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); detailUpdate.setDestinationWarehouseId(warehouseId); detailUpdate.setDestinationWarehouseCode(target.code()); detailUpdate.setDestinationWarehouseName(target.name()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); InboundPlanCarton cartonUpdate = new InboundPlanCarton(); cartonUpdate.setDestinationWarehouseId(warehouseId); cartonUpdate.setDestinationWarehouseCode(target.code()); cartonUpdate.setDestinationWarehouseName(target.name()); cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) .eq(InboundPlanCarton::getOrderId, order.getId())); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); history.setFromStatus(fromStatus); history.setToStatus(DESTINATION_ASSIGNED); history.setRemark(StringUtils.hasText(request.remark()) ? request.remark() : "Destination warehouse assigned: " + target.code()); history.setCreatedAt(now); historyMapper.insert(history); } 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) { List orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords(); if (orders.isEmpty()) break; for (LastlegOrder order : orders) { String line = String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), csv(order.getInboundCode()), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), csv(order.getPalletCount()), csv(order.getPackageCount()), 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(order.getRelabelStatus()), csv(order.getPalletMergeFlag()), csv(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(); if (orders.size() < batchSize) break; page++; } } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) .and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper .like(LastlegOrder::getOverseasWarehouseId, query.warehouse()) .or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse()) .or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse()) .or().like(LastlegOrder::getTransferWarehouseName, query.warehouse())) .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, query.transferWarehouseCode()) .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); } private LastlegOrder findOwned(String customerId, long orderId) { LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getId, orderId)); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); return order; } private void ensureTransferable(LastlegOrder order) { if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); } } private Map> palletMergeRelations(List orders) { if (orders.isEmpty()) return Map.of(); Map> result = new LinkedHashMap<>(); List rows = orderMapper.findPalletMergeRelations( orders.stream().map(LastlegOrder::getId).toList()); if (rows == null) return Map.of(); for (LastlegOrderMapper.PalletMergeRelationRow row : rows) { result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row); } return result; } private LastlegOrderSummary summary(LastlegOrder o, List relations) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } 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("\"", "\"\"") + '"'; } }