|
@@ -0,0 +1,430 @@
|
|
|
|
|
+package com.dtm.order.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.dtm.order.dto.CoPurchaseDTO;
|
|
|
|
|
+import com.dtm.order.dto.ProductDTO;
|
|
|
|
|
+import org.apache.poi.ss.usermodel.Row;
|
|
|
|
|
+import org.apache.poi.ss.usermodel.Sheet;
|
|
|
|
|
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.time.Duration;
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
|
|
+import java.time.format.DateTimeParseException;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
+
|
|
|
|
|
+@Service
|
|
|
|
|
+public class AnalysisService {
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ private final OrderDataStore orderDataStore;
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ public AnalysisService(OrderDataStore orderDataStore) {
|
|
|
|
|
+ this.orderDataStore = orderDataStore;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Double calculateTotalGMV(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ double sum = 0.0;
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ sum += order.getOrderActualPayment();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return sum;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public List<ProductDTO> getTop5Products(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ Map<String, Double> salesBySku = new HashMap<>();
|
|
|
|
|
+ Map<String, String> nameBySku = new HashMap<>();
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (!range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ String sku = order.getProductMerchantCode();
|
|
|
|
|
+ if (sku == null || sku.isEmpty()) {
|
|
|
|
|
+ sku = order.getProductId();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sku == null || sku.isEmpty()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ salesBySku.merge(sku, order.getOrderActualPayment(), Double::sum);
|
|
|
|
|
+ if (!nameBySku.containsKey(sku) && order.getProductTitle() != null) {
|
|
|
|
|
+ nameBySku.put(sku, order.getProductTitle());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<ProductDTO> result = new ArrayList<>();
|
|
|
|
|
+ salesBySku.entrySet().stream()
|
|
|
|
|
+ .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
|
|
|
|
|
+ .limit(5)
|
|
|
|
|
+ .forEach(entry -> {
|
|
|
|
|
+ String sku = entry.getKey();
|
|
|
|
|
+ String name = nameBySku.getOrDefault(sku, sku);
|
|
|
|
|
+ result.add(new ProductDTO(sku, name, entry.getValue()));
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Map<String, Double> calculateP80AndRBig(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ List<Double> payments = new ArrayList<>();
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (!range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ double payment = order.getOrderActualPayment();
|
|
|
|
|
+ if (payment <= 0 && order.getOrderPayable() > 0) {
|
|
|
|
|
+ payment = order.getOrderPayable();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payment > 0) {
|
|
|
|
|
+ payments.add(payment);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (payments.isEmpty()) {
|
|
|
|
|
+ Map<String, Double> empty = new HashMap<>();
|
|
|
|
|
+ empty.put("p80Threshold", 0.0);
|
|
|
|
|
+ empty.put("rBigRatio", 0.0);
|
|
|
|
|
+ return empty;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Collections.sort(payments);
|
|
|
|
|
+ int p80Index = (int) (payments.size() * 0.8);
|
|
|
|
|
+ if (p80Index >= payments.size()) p80Index = payments.size() - 1;
|
|
|
|
|
+ double p80Threshold = payments.get(p80Index);
|
|
|
|
|
+
|
|
|
|
|
+ double totalSales = 0.0;
|
|
|
|
|
+ double rBigSales = 0.0;
|
|
|
|
|
+ for (Double payment : payments) {
|
|
|
|
|
+ totalSales += payment;
|
|
|
|
|
+ if (payment >= p80Threshold) {
|
|
|
|
|
+ rBigSales += payment;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ double rBigRatio = (totalSales > 0) ? (rBigSales / totalSales) * 100 : 0.0;
|
|
|
|
|
+ Map<String, Double> result = new HashMap<>();
|
|
|
|
|
+ result.put("p80Threshold", p80Threshold);
|
|
|
|
|
+ result.put("rBigRatio", rBigRatio);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Map<String, Double> calculateLeakageRate(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ double totalRefundAmount = 0.0;
|
|
|
|
|
+ double totalSuccessAmount = 0.0;
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (!range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ double payment = order.getOrderActualPayment();
|
|
|
|
|
+ if (payment <= 0 && order.getOrderPayable() > 0) {
|
|
|
|
|
+ payment = order.getOrderPayable();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payment > 0) {
|
|
|
|
|
+ totalSuccessAmount += payment;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String refundStatus = normalize(order.getOrderRefundStatus());
|
|
|
|
|
+ double refundAmount = order.getOrderRefundAmount();
|
|
|
|
|
+ if (refundAmount > 0) {
|
|
|
|
|
+ totalRefundAmount += refundAmount;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ double leakageRate = (totalSuccessAmount > 0) ? (totalRefundAmount / totalSuccessAmount) * 100 : 0.0;
|
|
|
|
|
+ Map<String, Double> result = new HashMap<>();
|
|
|
|
|
+ result.put("totalRefundAmount", totalRefundAmount);
|
|
|
|
|
+ result.put("totalSuccessAmount", totalSuccessAmount);
|
|
|
|
|
+ result.put("leakageRatePercent", leakageRate);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Map<String, Object> getLeakageDebug() {
|
|
|
|
|
+ Map<String, Long> statusCounts = new HashMap<>();
|
|
|
|
|
+ double refundAmountSum = 0.0;
|
|
|
|
|
+ double successAmountSum = 0.0;
|
|
|
|
|
+ long refundAmountCount = 0;
|
|
|
|
|
+ long refundStatusCount = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ String refundStatus = normalize(order.getOrderRefundStatus());
|
|
|
|
|
+ if (!refundStatus.isEmpty()) {
|
|
|
|
|
+ statusCounts.merge(refundStatus, 1L, Long::sum);
|
|
|
|
|
+ refundStatusCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ double refundAmount = order.getOrderRefundAmount();
|
|
|
|
|
+ if (refundAmount > 0) {
|
|
|
|
|
+ refundAmountSum += refundAmount;
|
|
|
|
|
+ refundAmountCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ double payment = order.getOrderActualPayment();
|
|
|
|
|
+ if (payment <= 0 && order.getOrderPayable() > 0) {
|
|
|
|
|
+ payment = order.getOrderPayable();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payment > 0) {
|
|
|
|
|
+ successAmountSum += payment;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("orders", orderDataStore.getOrders().size());
|
|
|
|
|
+ result.put("refundAmountSum", refundAmountSum);
|
|
|
|
|
+ result.put("refundAmountCount", refundAmountCount);
|
|
|
|
|
+ result.put("refundStatusCount", refundStatusCount);
|
|
|
|
|
+ result.put("successAmountSum", successAmountSum);
|
|
|
|
|
+ result.put("refundStatusTop", statusCounts.entrySet().stream()
|
|
|
|
|
+ .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
|
|
|
|
+ .limit(10)
|
|
|
|
|
+ .collect(Collectors.toList()));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public List<CoPurchaseDTO> findCoPurchaseRules() {
|
|
|
|
|
+ Map<String, Set<String>> purchaseProducts = new HashMap<>();
|
|
|
|
|
+ Map<String, String> productTitle = new HashMap<>();
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ String purchaseKey = order.getPurchaseId();
|
|
|
|
|
+ if (purchaseKey == null || purchaseKey.isEmpty()) {
|
|
|
|
|
+ purchaseKey = order.getPurchasePaymentId();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (purchaseKey == null || purchaseKey.isEmpty()) {
|
|
|
|
|
+ purchaseKey = order.getOrderId();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (purchaseKey == null || purchaseKey.isEmpty()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String productId = order.getProductId();
|
|
|
|
|
+ if (productId == null || productId.isEmpty()) {
|
|
|
|
|
+ productId = order.getProductMerchantCode();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (productId == null || productId.isEmpty()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ purchaseProducts
|
|
|
|
|
+ .computeIfAbsent(purchaseKey, k -> new HashSet<>())
|
|
|
|
|
+ .add(productId);
|
|
|
|
|
+ if (order.getProductTitle() != null && !order.getProductTitle().isEmpty()) {
|
|
|
|
|
+ productTitle.putIfAbsent(productId, order.getProductTitle());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Long> pairCounts = new HashMap<>();
|
|
|
|
|
+ for (Set<String> products : purchaseProducts.values()) {
|
|
|
|
|
+ if (products.size() < 2) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> list = new ArrayList<>(products);
|
|
|
|
|
+ Collections.sort(list);
|
|
|
|
|
+ for (int i = 0; i < list.size(); i++) {
|
|
|
|
|
+ for (int j = i + 1; j < list.size(); j++) {
|
|
|
|
|
+ String a = list.get(i);
|
|
|
|
|
+ String b = list.get(j);
|
|
|
|
|
+ String key = a + "||" + b;
|
|
|
|
|
+ pairCounts.merge(key, 1L, Long::sum);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<Map.Entry<String, Long>> sortedPairs = new ArrayList<>(pairCounts.entrySet());
|
|
|
|
|
+ sortedPairs.sort(Map.Entry.<String, Long>comparingByValue().reversed());
|
|
|
|
|
+
|
|
|
|
|
+ List<CoPurchaseDTO> result = new ArrayList<>();
|
|
|
|
|
+ int limit = Math.min(50, sortedPairs.size());
|
|
|
|
|
+ for (int i = 0; i < limit; i++) {
|
|
|
|
|
+ String[] ids = sortedPairs.get(i).getKey().split("\\|\\|", 2);
|
|
|
|
|
+ String aId = ids[0];
|
|
|
|
|
+ String bId = ids.length > 1 ? ids[1] : "";
|
|
|
|
|
+ result.add(new CoPurchaseDTO(
|
|
|
|
|
+ productTitle.getOrDefault(aId, aId),
|
|
|
|
|
+ aId,
|
|
|
|
|
+ productTitle.getOrDefault(bId, bId),
|
|
|
|
|
+ bId,
|
|
|
|
|
+ sortedPairs.get(i).getValue()
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Double calculateAveragePaymentTime(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ double totalSeconds = 0.0;
|
|
|
|
|
+ long count = 0;
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (!range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (order.getCreatedTime() == null || order.getPaidTime() == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ long seconds = Duration.between(order.getCreatedTime(), order.getPaidTime()).getSeconds();
|
|
|
|
|
+ if (seconds >= 0) {
|
|
|
|
|
+ totalSeconds += seconds;
|
|
|
|
|
+ count++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return count == 0 ? 0.0 : totalSeconds / count;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Map<String, Long> analyzePaymentDecisionFunnel(String startDate, String endDate) {
|
|
|
|
|
+ LocalDateRange range = parseRange(startDate, endDate);
|
|
|
|
|
+ long paidWithin5Mins = 0;
|
|
|
|
|
+ long paidBetween5And30Mins = 0;
|
|
|
|
|
+ long paidAfter30Mins = 0;
|
|
|
|
|
+ long unpaidOrders = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (OrderRecord order : orderDataStore.getOrders()) {
|
|
|
|
|
+ if (!range.contains(order.getCreatedDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (order.getCreatedTime() == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (order.getPaidTime() == null) {
|
|
|
|
|
+ unpaidOrders++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ long seconds = Duration.between(order.getCreatedTime(), order.getPaidTime()).getSeconds();
|
|
|
|
|
+ if (seconds <= 300) {
|
|
|
|
|
+ paidWithin5Mins++;
|
|
|
|
|
+ } else if (seconds <= 1800) {
|
|
|
|
|
+ paidBetween5And30Mins++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ paidAfter30Mins++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Long> funnelData = new HashMap<>();
|
|
|
|
|
+ funnelData.put("paidWithin5Mins", paidWithin5Mins);
|
|
|
|
|
+ funnelData.put("paidBetween5And30Mins", paidBetween5And30Mins);
|
|
|
|
|
+ funnelData.put("paidAfter30Mins", paidAfter30Mins);
|
|
|
|
|
+ funnelData.put("unpaidOrders", unpaidOrders);
|
|
|
|
|
+ return funnelData;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public int importNewSalesData(InputStream inputStream) {
|
|
|
|
|
+ int successCount = 0;
|
|
|
|
|
+ try (XSSFWorkbook workbook = new XSSFWorkbook(inputStream)) {
|
|
|
|
|
+ Sheet sheet = workbook.getSheetAt(0);
|
|
|
|
|
+ int rowIndex = 0;
|
|
|
|
|
+ for (Row row : sheet) {
|
|
|
|
|
+ if (rowIndex++ == 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ successCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception ignored) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return successCount;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Double calculateTop5Percentage(String startDate, String endDate) {
|
|
|
|
|
+ List<ProductDTO> top5List = getTop5Products(startDate, endDate);
|
|
|
|
|
+ if (top5List == null || top5List.isEmpty()) {
|
|
|
|
|
+ return 0.0;
|
|
|
|
|
+ }
|
|
|
|
|
+ double top5Sum = top5List.stream()
|
|
|
|
|
+ .mapToDouble(ProductDTO::getTotalSales)
|
|
|
|
|
+ .sum();
|
|
|
|
|
+ Double totalGmv = calculateTotalGMV(startDate, endDate);
|
|
|
|
|
+ if (totalGmv == null || totalGmv <= 0) {
|
|
|
|
|
+ return 0.0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return (top5Sum / totalGmv) * 100;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public String getMaxOrderDate() {
|
|
|
|
|
+ LocalDate maxDate = orderDataStore.getMaxOrderDate();
|
|
|
|
|
+ if (maxDate != null) {
|
|
|
|
|
+ return maxDate.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ return LocalDate.now().toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LocalDateRange parseRange(String startDate, String endDate) {
|
|
|
|
|
+ LocalDate start = parseDate(startDate);
|
|
|
|
|
+ LocalDate end = parseDate(endDate);
|
|
|
|
|
+ if (start == null && end == null) {
|
|
|
|
|
+ return new LocalDateRange(null, null);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (start == null) {
|
|
|
|
|
+ start = end;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (end == null) {
|
|
|
|
|
+ end = start;
|
|
|
|
|
+ }
|
|
|
|
|
+ return new LocalDateRange(start, end);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LocalDate parseDate(String raw) {
|
|
|
|
|
+ if (raw == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String value = raw.trim();
|
|
|
|
|
+ if (value.isEmpty()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value.length() > 10) {
|
|
|
|
|
+ value = value.substring(0, 10);
|
|
|
|
|
+ }
|
|
|
|
|
+ value = value.replace('/', '-');
|
|
|
|
|
+ try {
|
|
|
|
|
+ return LocalDate.parse(value, DateTimeFormatter.ofPattern("yyyy-M-d"));
|
|
|
|
|
+ } catch (DateTimeParseException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalize(String value) {
|
|
|
|
|
+ return value == null ? "" : value.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static class LocalDateRange {
|
|
|
|
|
+ private final LocalDate start;
|
|
|
|
|
+ private final LocalDate end;
|
|
|
|
|
+
|
|
|
|
|
+ private LocalDateRange(LocalDate start, LocalDate end) {
|
|
|
|
|
+ this.start = start;
|
|
|
|
|
+ this.end = end;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public boolean contains(LocalDate date) {
|
|
|
|
|
+ if (date == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (start != null && date.isBefore(start)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (end != null && date.isAfter(end)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|