Subversion Repositories SmartDukaan

Rev

Rev 37704 | Blame | Compare with Previous | Last modification | View Log | RSS feed

package com.smartdukaan.cron.scheduled.purchaseorder;

import com.spice.profitmandi.common.model.ProfitMandiConstants;
import com.spice.profitmandi.dao.entity.auth.AuthUser;
import com.spice.profitmandi.dao.entity.inventory.InventoryWarehouse;
import com.spice.profitmandi.dao.entity.transaction.Order;
import com.spice.profitmandi.dao.entity.warehouse.Supplier;
import com.spice.profitmandi.dao.entity.warehouse.WarehouseLineItem;
import com.spice.profitmandi.dao.entity.warehouse.WarehousePurchaseOrder;
import com.spice.profitmandi.dao.entity.warehouse.WarehouseSupplierInvoice;
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
import com.spice.profitmandi.dao.enumuration.warehouse.WarehouseInvoiceStatus;
import com.spice.profitmandi.dao.repository.cs.CsService;
import com.spice.profitmandi.dao.repository.inventory.InventoryWarehouseRepository;
import com.spice.profitmandi.dao.repository.transaction.OrderRepository;
import com.spice.profitmandi.dao.repository.warehouse.SupplierRepository;
import com.spice.profitmandi.dao.repository.warehouse.WarehouseLineItemRepository;
import com.spice.profitmandi.dao.repository.warehouse.WarehousePurchaseOrderRepository;
import com.spice.profitmandi.dao.repository.warehouse.WarehouseSupplierInvoiceRepository;
import com.spice.profitmandi.service.mail.MailOutboxService;
import in.shop2020.purchase.POStatus;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

@Component
@Transactional(rollbackFor = Throwable.class)
public class POScheduler {

    private static final Logger LOGGER = LogManager.getLogger(POScheduler.class);

    private static final int VENDOR_PO_OPEN_DAYS = 6;
    private static final int MOVEMENT_PO_ALERT_DAYS = 4;

    @Autowired
    WarehousePurchaseOrderRepository warehousePurchaseOrderRepository;
    @Autowired
    SupplierRepository supplierRepository;
    @Autowired
    WarehouseLineItemRepository warehouseLineItemRepository;
    @Autowired
    WarehouseSupplierInvoiceRepository warehouseSupplierInvoiceRepository;
    @Autowired
    OrderRepository orderRepository;
    @Autowired
    InventoryWarehouseRepository inventoryWarehouseRepository;
    @Autowired
    CsService csService;
    @Autowired
    MailOutboxService mailOutboxService;

    // Closes vendor POs that have been open longer than their window. Movements are never closed here: one stays open
    // until it is received, its orders are cancelled, or it is closed by hand - see sendOpenMovementDigest.
    public void autoClosePurchaseOrders() {
        Set<Integer> internalSupplierIds = this.internalSupplierIds();
        LocalDate cutoff = LocalDate.now().minusDays(VENDOR_PO_OPEN_DAYS);
        List<WarehousePurchaseOrder> posToClose = this.openPurchaseOrders().stream()
                .filter(po -> !internalSupplierIds.contains(po.getSupplierId()))
                .filter(po -> po.getCreatedAt().toLocalDate().isBefore(cutoff))
                .collect(Collectors.toList());
        posToClose.forEach(po -> po.setStatus(POStatus.CLOSED));
        LOGGER.info("Auto-closed {} vendor POs", posToClose.size());
    }

    // Mails the warehouse team every movement PO still open after MOVEMENT_PO_ALERT_DAYS, with what is holding it.
    public void sendOpenMovementDigest() {
        Map<Integer, Supplier> internalSuppliers = supplierRepository.selectAll().stream().filter(Supplier::isInternal)
                .collect(Collectors.toMap(Supplier::getId, x -> x));
        LocalDate cutoff = LocalDate.now().minusDays(MOVEMENT_PO_ALERT_DAYS);
        List<WarehousePurchaseOrder> overdue = this.openPurchaseOrders().stream()
                .filter(po -> internalSuppliers.containsKey(po.getSupplierId()))
                .filter(po -> !po.getCreatedAt().toLocalDate().isAfter(cutoff))
                .sorted(Comparator.comparing(WarehousePurchaseOrder::getWarehouseId).thenComparing(WarehousePurchaseOrder::getCreatedAt))
                .collect(Collectors.toList());
        if (overdue.isEmpty()) {
            LOGGER.info("No movement PO open beyond {} days", MOVEMENT_PO_ALERT_DAYS);
            return;
        }

        String[] recipients = csService.getAuthUserIds(ProfitMandiConstants.TICKET_CATEGORY_WAREHOUSE,
                Arrays.asList(EscalationType.L1, EscalationType.L2)).stream()
                .map(AuthUser::getEmailId).filter(Objects::nonNull).distinct().toArray(String[]::new);
        if (recipients.length == 0) {
            LOGGER.warn("No warehouse L1/L2 recipients for the open movement PO digest");
            return;
        }

        Map<Integer, String> warehouseNames = new HashMap<>();
        StringBuilder rows = new StringBuilder();
        for (WarehousePurchaseOrder po : overdue) {
            Supplier supplier = internalSuppliers.get(po.getSupplierId());
            List<WarehouseLineItem> lines = warehouseLineItemRepository.selectAllByPurchaseOrderId(po.getId());
            int openQty = lines.stream().filter(x -> !x.isDiscarded()).mapToInt(WarehouseLineItem::getUnfulfilledQuantity).sum();
            double openValue = lines.stream().filter(x -> !x.isDiscarded())
                    .mapToDouble(x -> x.getUnfulfilledQuantity() * (double) x.getUnitPrice()).sum();
            rows.append("<tr><td>").append(po.getPoNumber()).append("</td><td>")
                    .append(this.warehouseName(supplier.getInternalWarehouseId(), warehouseNames)).append("</td><td>")
                    .append(this.warehouseName(po.getWarehouseId(), warehouseNames)).append("</td><td>")
                    .append(po.getCreatedAt().toLocalDate().format(DateTimeFormatter.ofPattern("dd-MMM-yy"))).append("</td><td>")
                    .append(ChronoUnit.DAYS.between(po.getCreatedAt().toLocalDate(), LocalDate.now())).append("</td><td>")
                    .append(openQty).append("</td><td>").append(String.format("%,.0f", openValue)).append("</td><td>")
                    .append(this.holdingReason(po)).append("</td></tr>");
        }

        String body = "<p>Movement purchase orders open for " + MOVEMENT_PO_ALERT_DAYS + " days or more. Receive the billed "
                + "invoices against their own PO, or cancel orders that will not ship.</p>"
                + "<table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;font-family:Arial;font-size:12px'>"
                + "<tr style='background:#1F3864;color:#fff'><th>PO</th><th>From</th><th>To</th><th>PO date</th><th>Days open</th>"
                + "<th>Open qty</th><th>Open value (Rs)</th><th>Holding it</th></tr>" + rows + "</table>";
        mailOutboxService.queueMailViaGoogle(recipients, null,
                "Open movement POs (" + overdue.size() + ") - " + LocalDate.now().format(DateTimeFormatter.ofPattern("dd-MMM-yyyy")),
                body, true, "POScheduler.sendOpenMovementDigest");
        LOGGER.info("Open movement PO digest queued: {} POs to {}", overdue.size(), Arrays.toString(recipients));
    }

    private String holdingReason(WarehousePurchaseOrder po) {
        if (po.getTransactionId() == null || po.getTransactionId() <= 0) {
            return "Receive-only PO: invoice not yet received";
        }
        List<Order> live = orderRepository.selectAllByTransactionId(po.getTransactionId()).stream()
                .filter(o -> o.getRefundTimestamp() == null).collect(Collectors.toList());
        List<String> notBilled = live.stream().filter(o -> o.getBillingTimestamp() == null)
                .map(o -> String.valueOf(o.getId())).collect(Collectors.toList());
        List<String> invoices = live.stream().map(Order::getInvoiceNumber).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        Set<String> received = invoices.isEmpty() ? Collections.<String>emptySet()
                : warehouseSupplierInvoiceRepository.selectAllBySupplierInvoice(po.getSupplierId(), invoices).stream()
                .filter(x -> WarehouseInvoiceStatus.received.equals(x.getStatus()))
                .map(WarehouseSupplierInvoice::getInvoiceNumber).collect(Collectors.toSet());
        List<String> reasons = new ArrayList<>();
        if (!notBilled.isEmpty()) {
            reasons.add("Not billed: order " + String.join(", ", notBilled));
        }
        List<String> pending = invoices.stream().filter(x -> !received.contains(x)).collect(Collectors.toList());
        if (!pending.isEmpty()) {
            reasons.add("Billed, not received: " + String.join(", ", pending));
        }
        return reasons.isEmpty() ? "No live order - open quantity will not arrive; close the PO" : String.join("<br>", reasons);
    }

    private String warehouseName(Integer warehouseId, Map<Integer, String> cache) {
        if (warehouseId == null) {
            return "-";
        }
        return cache.computeIfAbsent(warehouseId, id -> {
            InventoryWarehouse warehouse = inventoryWarehouseRepository.selectById(id);
            return warehouse == null ? String.valueOf(id) : id + " " + warehouse.getDisplayName();
        });
    }

    private List<WarehousePurchaseOrder> openPurchaseOrders() {
        List<WarehousePurchaseOrder> open = new ArrayList<>(warehousePurchaseOrderRepository.selectAllByStatus(POStatus.READY));
        open.addAll(warehousePurchaseOrderRepository.selectAllByStatus(POStatus.PARTIALLY_FULFILLED));
        return open;
    }

    private Set<Integer> internalSupplierIds() {
        return supplierRepository.selectAll().stream().filter(Supplier::isInternal).map(Supplier::getId).collect(Collectors.toSet());
    }
}