From 9d66bfbfcda297f628e6a857e343f98422f4534a Mon Sep 17 00:00:00 2001
From: liyong <18434998025@163.com>
Date: 星期五, 22 五月 2026 09:32:28 +0800
Subject: [PATCH] Merge remote-tracking branch 'refs/remotes/origin/dev_New_pro' into dev_New_pro_OA

---
 src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java |  311 ++++++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 257 insertions(+), 54 deletions(-)

diff --git a/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
index 17b6868..bdd1f1f 100644
--- a/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
+++ b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
@@ -2,23 +2,29 @@
 
 import com.alibaba.fastjson2.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.account.mapper.purchase.AccountPaymentApplicationMapper;
+import com.ruoyi.account.mapper.purchase.AccountPurchaseInvoiceMapper;
+import com.ruoyi.account.mapper.purchase.AccountPurchasePaymentMapper;
+import com.ruoyi.account.pojo.purchase.AccountPaymentApplication;
+import com.ruoyi.account.pojo.purchase.AccountPurchaseInvoice;
+import com.ruoyi.account.pojo.purchase.AccountPurchasePayment;
 import com.ruoyi.ai.context.AiSessionUserContext;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.framework.security.LoginUser;
-import com.ruoyi.purchase.mapper.InvoicePurchaseMapper;
-import com.ruoyi.purchase.mapper.PaymentRegistrationMapper;
-import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
-import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
-import com.ruoyi.purchase.pojo.InvoicePurchase;
-import com.ruoyi.purchase.pojo.PaymentRegistration;
-import com.ruoyi.purchase.pojo.PurchaseLedger;
-import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
 import com.ruoyi.procurementrecord.mapper.InboundManagementMapper;
 import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
 import com.ruoyi.procurementrecord.pojo.InboundManagement;
 import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
+import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
+import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
+import com.ruoyi.purchase.pojo.PurchaseLedger;
+import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
+import com.ruoyi.quality.mapper.QualityInspectMapper;
+import com.ruoyi.quality.pojo.QualityInspect;
 import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
 import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import com.ruoyi.stock.mapper.StockInRecordMapper;
+import com.ruoyi.stock.pojo.StockInRecord;
 import dev.langchain4j.agent.tool.P;
 import dev.langchain4j.agent.tool.Tool;
 import dev.langchain4j.agent.tool.ToolMemoryId;
@@ -27,48 +33,52 @@
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
-import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
-import java.util.Date;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Comparator;
+import java.util.*;
 import java.util.stream.Collectors;
 
 @Component
 public class PurchaseAgentTools {
 
     private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final ZoneId CHINA_ZONE_ID = ZoneId.of("Asia/Shanghai");
     private static final int DEFAULT_LIMIT = 10;
     private static final int MAX_LIMIT = 30;
 
     private final PurchaseLedgerMapper purchaseLedgerMapper;
-    private final PaymentRegistrationMapper paymentRegistrationMapper;
-    private final InvoicePurchaseMapper invoicePurchaseMapper;
     private final PurchaseReturnOrdersMapper purchaseReturnOrdersMapper;
     private final SalesLedgerProductMapper salesLedgerProductMapper;
     private final ProcurementRecordMapper procurementRecordMapper;
     private final InboundManagementMapper inboundManagementMapper;
+    private final AccountPurchasePaymentMapper accountPurchasePaymentMapper;
+    private final AccountPaymentApplicationMapper accountPaymentApplicationMapper;
+    private final AccountPurchaseInvoiceMapper accountPurchaseInvoiceMapper;
+    private final StockInRecordMapper stockInRecordMapper;
+    private final QualityInspectMapper qualityInspectMapper;
     private final AiSessionUserContext aiSessionUserContext;
 
     public PurchaseAgentTools(PurchaseLedgerMapper purchaseLedgerMapper,
-                              PaymentRegistrationMapper paymentRegistrationMapper,
-                              InvoicePurchaseMapper invoicePurchaseMapper,
                               PurchaseReturnOrdersMapper purchaseReturnOrdersMapper,
                               SalesLedgerProductMapper salesLedgerProductMapper,
                               ProcurementRecordMapper procurementRecordMapper,
                               InboundManagementMapper inboundManagementMapper,
+                              AccountPurchasePaymentMapper accountPurchasePaymentMapper,
+                              AccountPaymentApplicationMapper accountPaymentApplicationMapper,
+                              AccountPurchaseInvoiceMapper accountPurchaseInvoiceMapper,
+                              StockInRecordMapper stockInRecordMapper,
+                              QualityInspectMapper qualityInspectMapper,
                               AiSessionUserContext aiSessionUserContext) {
         this.purchaseLedgerMapper = purchaseLedgerMapper;
-        this.paymentRegistrationMapper = paymentRegistrationMapper;
-        this.invoicePurchaseMapper = invoicePurchaseMapper;
         this.purchaseReturnOrdersMapper = purchaseReturnOrdersMapper;
         this.salesLedgerProductMapper = salesLedgerProductMapper;
         this.procurementRecordMapper = procurementRecordMapper;
         this.inboundManagementMapper = inboundManagementMapper;
+        this.accountPurchasePaymentMapper = accountPurchasePaymentMapper;
+        this.accountPaymentApplicationMapper = accountPaymentApplicationMapper;
+        this.accountPurchaseInvoiceMapper = accountPurchaseInvoiceMapper;
+        this.stockInRecordMapper = stockInRecordMapper;
+        this.qualityInspectMapper = qualityInspectMapper;
         this.aiSessionUserContext = aiSessionUserContext;
     }
 
@@ -130,8 +140,8 @@
         DateRange range = resolveDateRange(startDate, endDate, timeRange);
 
         List<PurchaseLedger> ledgers = queryLedgers(loginUser, range);
-        List<PaymentRegistration> payments = queryPayments(loginUser, range);
-        List<InvoicePurchase> invoices = queryInvoices(loginUser, range);
+        List<AccountPurchasePayment> payments = queryPayments(loginUser, range);
+        List<AccountPurchaseInvoice> invoices = queryInvoices(loginUser, range);
         List<PurchaseReturnOrders> returns = queryReturns(loginUser, range);
 
         BigDecimal contractAmount = ledgers.stream()
@@ -139,11 +149,11 @@
                 .filter(Objects::nonNull)
                 .reduce(BigDecimal.ZERO, BigDecimal::add);
         BigDecimal paymentAmount = payments.stream()
-                .map(PaymentRegistration::getCurrentPaymentAmount)
+                .map(AccountPurchasePayment::getPaymentAmount)
                 .filter(Objects::nonNull)
                 .reduce(BigDecimal.ZERO, BigDecimal::add);
         BigDecimal invoiceAmount = invoices.stream()
-                .map(InvoicePurchase::getInvoiceAmount)
+                .map(this::invoiceAmountOf)
                 .filter(Objects::nonNull)
                 .reduce(BigDecimal.ZERO, BigDecimal::add);
         BigDecimal returnAmount = returns.stream()
@@ -279,15 +289,37 @@
                                            @P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�30", required = false) Integer limit) {
         LoginUser loginUser = currentLoginUser(memoryId);
         DateRange range = resolveDateRange(startDate, endDate, null);
-        List<Map<String, Object>> items = queryLedgers(loginUser, range).stream()
+        List<PurchaseLedger> matchedLedgers = queryLedgers(loginUser, range).stream()
                 .filter(ledger -> matchLedgerKeyword(ledger, keyword))
-                .map(ledger -> toPendingPaymentItem(loginUser, ledger))
+                .collect(Collectors.toList());
+        Map<Long, BigDecimal> paidAmountByLedgerId = sumPaymentAmountByLedgerId(loginUser, matchedLedgers.stream()
+                .map(PurchaseLedger::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList()));
+        List<Map<String, Object>> items = matchedLedgers.stream()
+                .map(ledger -> toPendingPaymentItem(ledger, paidAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO)))
                 .filter(Objects::nonNull)
                 .sorted(Comparator.comparing(item -> (BigDecimal) item.get("pendingAmount"), Comparator.reverseOrder()))
                 .limit(normalizeLimit(limit))
                 .collect(Collectors.toList());
+
+        BigDecimal totalContractAmount = items.stream()
+                .map(item -> asBigDecimal(item.get("contractAmount")))
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal totalPaidAmount = items.stream()
+                .map(item -> asBigDecimal(item.get("paidAmount")))
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal totalPendingAmount = items.stream()
+                .map(item -> asBigDecimal(item.get("pendingAmount")))
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        Map<String, Object> summary = rangeSummary(range, items.size());
+        summary.put("pendingOrderCount", items.size());
+        summary.put("totalContractAmount", totalContractAmount);
+        summary.put("totalPaidAmount", totalPaidAmount);
+        summary.put("totalPendingAmount", totalPendingAmount);
+
         return jsonResponse(true, "purchase_pending_payment_list", "宸茶繑鍥炲緟浠樻閲囪喘鍗曘��",
-                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
+                summary, Map.of("items", items), Map.of());
     }
 
     @Tool(name = "鏌ヨ閲囪喘閫�璐ф儏鍐�", value = "鎸夋椂闂磋寖鍥存煡璇㈤噰璐��璐у崟鍒楄〃鍜岄��璐ч噾棰濄��")
@@ -406,27 +438,58 @@
         return map;
     }
 
-    private Map<String, Object> toPendingPaymentItem(LoginUser loginUser, PurchaseLedger ledger) {
+    private Map<String, Object> toPendingPaymentItem(PurchaseLedger ledger, BigDecimal paidAmount) {
         BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
-        BigDecimal paidAmount = sumPaymentAmount(loginUser, ledger.getId());
-        BigDecimal pendingAmount = contractAmount.subtract(paidAmount);
+        BigDecimal safePaidAmount = defaultDecimal(paidAmount);
+        BigDecimal pendingAmount = contractAmount.subtract(safePaidAmount);
         if (pendingAmount.compareTo(BigDecimal.ZERO) <= 0) {
             return null;
         }
         Map<String, Object> item = toLedgerItem(ledger);
-        item.put("paidAmount", paidAmount);
+        item.put("paidAmount", safePaidAmount);
         item.put("pendingAmount", pendingAmount);
         return item;
     }
 
-    private BigDecimal sumPaymentAmount(LoginUser loginUser, Long purchaseLedgerId) {
-        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
-        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
-        wrapper.eq(PaymentRegistration::getPurchaseLedgerId, purchaseLedgerId);
-        return defaultList(paymentRegistrationMapper.selectList(wrapper)).stream()
-                .map(PaymentRegistration::getCurrentPaymentAmount)
-                .filter(Objects::nonNull)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
+    private Map<Long, BigDecimal> sumPaymentAmountByLedgerId(LoginUser loginUser, List<Long> purchaseLedgerIds) {
+        if (purchaseLedgerIds == null || purchaseLedgerIds.isEmpty()) {
+            return Map.of();
+        }
+        List<AccountPurchasePayment> payments = queryPayments(loginUser);
+        if (payments.isEmpty()) {
+            return Map.of();
+        }
+
+        Map<Integer, AccountPaymentApplication> applicationById = queryPaymentApplications(payments);
+        if (applicationById.isEmpty()) {
+            return Map.of();
+        }
+
+        Map<Long, StockInRecord> stockInRecordById = queryStockInRecords(applicationById.values());
+        Map<Long, Long> purchaseLedgerIdByQualityInspectId = queryPurchaseLedgerIdByQualityInspectId(stockInRecordById.values());
+        Set<Long> targetLedgerIdSet = new HashSet<>(purchaseLedgerIds);
+        Map<Long, BigDecimal> result = new HashMap<>();
+
+        for (AccountPurchasePayment payment : payments) {
+            if (payment.getAccountPaymentApplicationId() == null) {
+                continue;
+            }
+            AccountPaymentApplication application = applicationById.get(payment.getAccountPaymentApplicationId());
+            if (application == null) {
+                continue;
+            }
+            Set<Long> ledgerIds = resolvePurchaseLedgerIds(application, stockInRecordById, purchaseLedgerIdByQualityInspectId);
+            if (ledgerIds.isEmpty()) {
+                continue;
+            }
+            BigDecimal amount = defaultDecimal(payment.getPaymentAmount());
+            for (Long ledgerId : ledgerIds) {
+                if (targetLedgerIdSet.contains(ledgerId)) {
+                    result.merge(ledgerId, amount, BigDecimal::add);
+                }
+            }
+        }
+        return result;
     }
 
     private Map<String, Object> toReturnItem(PurchaseReturnOrders item) {
@@ -446,21 +509,141 @@
         return value == null ? BigDecimal.ZERO : value;
     }
 
-    private List<PaymentRegistration> queryPayments(LoginUser loginUser, DateRange range) {
-        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
-        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
-        wrapper.ge(PaymentRegistration::getPaymentDate, toDate(range.start()))
-                .lt(PaymentRegistration::getPaymentDate, toExclusiveEndDate(range.end()));
-        return defaultList(paymentRegistrationMapper.selectList(wrapper));
+    private BigDecimal asBigDecimal(Object value) {
+        if (value == null) {
+            return BigDecimal.ZERO;
+        }
+        if (value instanceof BigDecimal decimal) {
+            return decimal;
+        }
+        if (value instanceof Number number) {
+            return new BigDecimal(String.valueOf(number));
+        }
+        try {
+            return new BigDecimal(String.valueOf(value));
+        } catch (Exception ignored) {
+            return BigDecimal.ZERO;
+        }
     }
 
-    private List<InvoicePurchase> queryInvoices(LoginUser loginUser, DateRange range) {
-        LambdaQueryWrapper<InvoicePurchase> wrapper = new LambdaQueryWrapper<>();
-        applyTenantFilter(wrapper, loginUser.getTenantId(), InvoicePurchase::getTenantId);
-        wrapper.ge(InvoicePurchase::getIssueDate, range.start())
-                .le(InvoicePurchase::getIssueDate, range.end());
-        return defaultList(invoicePurchaseMapper.selectList(wrapper));
+    private BigDecimal invoiceAmountOf(AccountPurchaseInvoice invoice) {
+        if (invoice == null) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal amount = defaultDecimal(invoice.getTaxInclusivePrice());
+        if (amount.compareTo(BigDecimal.ZERO) > 0) {
+            return amount;
+        }
+        return defaultDecimal(invoice.getTaxExclusivelPrice()).add(defaultDecimal(invoice.getTaxPrice()));
     }
+
+    private List<AccountPurchasePayment> queryPayments(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
+        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
+        wrapper.ge(AccountPurchasePayment::getPaymentDate, range.start())
+                .le(AccountPurchasePayment::getPaymentDate, range.end())
+                .orderByDesc(AccountPurchasePayment::getPaymentDate, AccountPurchasePayment::getId);
+        return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
+    }
+
+    private List<AccountPurchasePayment> queryPayments(LoginUser loginUser) {
+        LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
+        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
+        return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
+    }
+
+    private List<AccountPurchaseInvoice> queryInvoices(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<AccountPurchaseInvoice> wrapper = new LambdaQueryWrapper<>();
+        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchaseInvoice::getDeptId);
+        wrapper.ge(AccountPurchaseInvoice::getIssueDate, range.start())
+                .le(AccountPurchaseInvoice::getIssueDate, range.end())
+                .orderByDesc(AccountPurchaseInvoice::getIssueDate, AccountPurchaseInvoice::getId);
+        return defaultList(accountPurchaseInvoiceMapper.selectList(wrapper));
+    }
+
+    private Map<Integer, AccountPaymentApplication> queryPaymentApplications(List<AccountPurchasePayment> payments) {
+        List<Integer> ids = payments.stream()
+                .map(AccountPurchasePayment::getAccountPaymentApplicationId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (ids.isEmpty()) {
+            return Map.of();
+        }
+        return defaultList(accountPaymentApplicationMapper.selectBatchIds(ids)).stream()
+                .filter(item -> item.getId() != null)
+                .collect(Collectors.toMap(AccountPaymentApplication::getId, item -> item, (a, b) -> a));
+    }
+
+    private Map<Long, StockInRecord> queryStockInRecords(Collection<AccountPaymentApplication> applications) {
+        Set<Long> stockInRecordIds = new HashSet<>();
+        for (AccountPaymentApplication application : applications) {
+            stockInRecordIds.addAll(parseLongIds(application.getStockInRecordIds()));
+        }
+        if (stockInRecordIds.isEmpty()) {
+            return Map.of();
+        }
+        return defaultList(stockInRecordMapper.selectBatchIds(stockInRecordIds)).stream()
+                .filter(item -> item.getId() != null)
+                .collect(Collectors.toMap(StockInRecord::getId, item -> item, (a, b) -> a));
+    }
+
+    private Map<Long, Long> queryPurchaseLedgerIdByQualityInspectId(Collection<StockInRecord> stockInRecords) {
+        Set<Long> qualityInspectIds = stockInRecords.stream()
+                .filter(Objects::nonNull)
+                .filter(item -> item.getRecordId() != null && "10".equals(safe(item.getRecordType()).trim()))
+                .map(StockInRecord::getRecordId)
+                .collect(Collectors.toSet());
+        if (qualityInspectIds.isEmpty()) {
+            return Map.of();
+        }
+        return defaultList(qualityInspectMapper.selectBatchIds(qualityInspectIds)).stream()
+                .filter(item -> item.getId() != null && item.getPurchaseLedgerId() != null)
+                .collect(Collectors.toMap(QualityInspect::getId, QualityInspect::getPurchaseLedgerId, (a, b) -> a));
+    }
+
+    private Set<Long> resolvePurchaseLedgerIds(AccountPaymentApplication application,
+                                               Map<Long, StockInRecord> stockInRecordById,
+                                               Map<Long, Long> purchaseLedgerIdByQualityInspectId) {
+        Set<Long> result = new LinkedHashSet<>();
+        for (Long stockInRecordId : parseLongIds(application.getStockInRecordIds())) {
+            StockInRecord stockInRecord = stockInRecordById.get(stockInRecordId);
+            if (stockInRecord == null || stockInRecord.getRecordId() == null) {
+                continue;
+            }
+            if (stockInRecord.getApprovalStatus() != null && stockInRecord.getApprovalStatus() != 1) {
+                continue;
+            }
+            String recordType = safe(stockInRecord.getRecordType()).trim();
+            if ("7".equals(recordType)) {
+                result.add(stockInRecord.getRecordId());
+            } else if ("10".equals(recordType)) {
+                Long purchaseLedgerId = purchaseLedgerIdByQualityInspectId.get(stockInRecord.getRecordId());
+                if (purchaseLedgerId != null) {
+                    result.add(purchaseLedgerId);
+                }
+            }
+        }
+        return result;
+    }
+
+    private List<Long> parseLongIds(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return List.of();
+        }
+        List<Long> result = new ArrayList<>();
+        for (String part : raw.split(",")) {
+            if (!StringUtils.hasText(part)) {
+                continue;
+            }
+            try {
+                result.add(Long.parseLong(part.trim()));
+            } catch (Exception ignored) {
+            }
+        }
+        return result;
+    }
+
 
     private List<PurchaseReturnOrders> queryReturns(LoginUser loginUser, DateRange range) {
         LambdaQueryWrapper<PurchaseReturnOrders> wrapper = new LambdaQueryWrapper<>();
@@ -484,7 +667,7 @@
     }
 
     private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
-        LocalDate today = LocalDate.now();
+        LocalDate today = LocalDate.now(CHINA_ZONE_ID);
         LocalDate start = parseLocalDate(startDate);
         LocalDate end = parseLocalDate(endDate);
         if (start != null || end != null) {
@@ -501,6 +684,22 @@
             return new DateRange(today.minusDays(29), today, "杩�30澶�");
         }
         String text = timeRange.trim();
+        if (text.contains("浠婂ぉ")) {
+            return new DateRange(today, today, "浠婂ぉ");
+        }
+        if (text.contains("鏄ㄥぉ")) {
+            LocalDate yesterday = today.minusDays(1);
+            return new DateRange(yesterday, yesterday, "鏄ㄥぉ");
+        }
+        if (text.contains("鏈懆")) {
+            LocalDate startOfWeek = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+            return new DateRange(startOfWeek, today, "鏈懆");
+        }
+        if (text.contains("涓婂懆")) {
+            LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+            LocalDate startOfLastWeek = thisWeekStart.minusWeeks(1);
+            return new DateRange(startOfLastWeek, startOfLastWeek.plusDays(6), "涓婂懆");
+        }
         if (text.contains("浠婂勾") || text.contains("鏈勾")) {
             return new DateRange(today.withDayOfYear(1), today, "浠婂勾");
         }
@@ -538,7 +737,11 @@
         if (!StringUtils.hasText(text)) {
             return null;
         }
-        return LocalDate.parse(text.trim(), DATE_FMT);
+        try {
+            return LocalDate.parse(text.trim(), DATE_FMT);
+        } catch (Exception ignored) {
+            return null;
+        }
     }
 
     private Date toDate(LocalDate localDate) {

--
Gitblit v1.9.3