Subversion Repositories SmartDukaan

Rev

Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

package com.smartdukaan.cron.migrations;

import com.spice.profitmandi.common.enumuration.ItemType;
import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
import com.spice.profitmandi.common.model.ProfitMandiConstants;
import com.spice.profitmandi.dao.entity.auth.AuthUser;
import com.spice.profitmandi.dao.entity.catalog.Item;
import com.spice.profitmandi.dao.entity.inventory.InventoryWarehouse;
import com.spice.profitmandi.dao.entity.inventory.SaholicInventorySnapshot;
import com.spice.profitmandi.dao.entity.transaction.Order;
import com.spice.profitmandi.dao.entity.warehouse.Supplier;
import com.spice.profitmandi.dao.entity.warehouse.WarehouseInventoryItem;
import com.spice.profitmandi.dao.entity.warehouse.WarehousePurchaseOrder;
import com.spice.profitmandi.dao.model.transaction.OrderBillingModel;
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
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.WarehouseInventoryItemRepository;
import com.spice.profitmandi.dao.repository.warehouse.WarehousePurchaseOrderRepository;
import com.spice.profitmandi.service.inventory.SaholicInventoryService;
import com.spice.profitmandi.service.transaction.TransactionService;
import com.spice.profitmandi.service.warehouse.WarehouseServiceImpl;
import in.shop2020.model.v1.inventory.InventoryType;
import in.shop2020.model.v1.inventory.WarehouseType;
import in.shop2020.model.v1.order.OrderStatus;
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.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Bills the orders an internal movement PO raised, the way a biller does it on the order management screen: an order
 * parked on the Dummy (its brand has no source mapping) is first pointed at the vendor warehouse that holds the stock,
 * serialized units are taken oldest first from that vendor warehouse - only IMEIs whose last scan leaves them in stock -
 * and the whole transaction is billed as one invoice through acceptOrders / addBillingDetailsForGrouppedOrders.
 *
 * The plan is worked out the same way for a dry run and a live one, and the live run re-plans inside its transaction,
 * so nothing is billed unless every order of the transaction can be. The dry run makes no billing calls at all:
 * billing opens a REQUIRES_NEW transaction of its own (createLoanForBilling) that a rollback would not undo.
 */
@Component
public class MovementBiller {

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

    // NIC rejects an IRN with more than 1000 item lines; one order is one line.
    private static final int MAX_INVOICE_LINES = 900;

    @Autowired
    private WarehousePurchaseOrderRepository warehousePurchaseOrderRepository;
    @Autowired
    private SupplierRepository supplierRepository;
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private TransactionService transactionService;
    @Autowired
    private SaholicInventoryService saholicInventoryService;
    @Autowired
    private InventoryWarehouseRepository inventoryWarehouseRepository;
    @Autowired
    private WarehouseInventoryItemRepository warehouseInventoryItemRepository;
    @Autowired
    private AuthRepository authRepository;

    /** One invoice line: an order, the vendor warehouse it bills from and the IMEIs it takes. */
    static class Line {
        int orderId;
        int itemId;
        String item;
        int qty;
        int fromWarehouseId;
        boolean repoint;
        boolean serialized;
        List<String> imeis = new ArrayList<>();
    }

    static class Plan {
        int transactionId;
        String poNumber;
        int sourceWarehouseId;
        int destinationWarehouseId;
        List<Line> lines = new ArrayList<>();
        List<String> problems = new ArrayList<>();
    }

    @Transactional(readOnly = true, rollbackFor = Throwable.class)
    public Plan describe(int transactionId) throws ProfitMandiBusinessException {
        return this.plan(transactionId);
    }

    /** Bills the whole transaction or nothing. Returns the invoice number. */
    @Transactional(rollbackFor = Throwable.class)
    public String bill(int transactionId, String operatorEmail, int logisticsProviderId) throws Exception {
        AuthUser operator = authRepository.selectByEmailOrMobile(operatorEmail);
        if (operator == null) {
            throw new ProfitMandiBusinessException("email", operatorEmail, "No auth user for " + operatorEmail);
        }
        Plan plan = this.plan(transactionId);
        if (!plan.problems.isEmpty()) {
            throw new ProfitMandiBusinessException("transaction", transactionId, String.join("; ", plan.problems));
        }

        // Point Dummy-parked orders at the vendor warehouse holding the stock, splitting an order whose quantity
        // comes from more than one vendor warehouse. The remainder of a split keeps the Dummy until its own chunk.
        Map<Integer, Integer> remainderOf = new HashMap<>();
        for (Line line : plan.lines) {
            if (!line.repoint) {
                continue;
            }
            int orderId = remainderOf.getOrDefault(line.orderId, line.orderId);
            Order order = orderRepository.selectById(orderId);
            int orderQty = order.getLineItem().getQuantity();
            if (line.qty < orderQty) {
                Order remainder = transactionService.splitOrder(orderId, orderQty - line.qty);
                remainderOf.put(line.orderId, remainder.getId());
            }
            transactionService.changeFulfillmentWarehouse(orderId, line.fromWarehouseId);
            line.orderId = orderId;
        }

        List<OrderBillingModel> models = new ArrayList<>();
        for (Line line : plan.lines) {
            OrderBillingModel model = new OrderBillingModel();
            model.setOrderId(line.orderId);
            model.setQuantityToBill(line.qty);
            model.setSerialNumbersToBill(line.imeis.stream().map(String::toLowerCase).collect(Collectors.toList()));
            model.setLogisticsProviderId(logisticsProviderId);
            model.setDistance("");
            models.add(model);
        }
        transactionService.acceptOrders(models);
        String invoiceNumber = transactionService.addBillingDetailsForGrouppedOrders(models, plan.sourceWarehouseId,
                operator.getFirstName());
        if (invoiceNumber != null) {
            transactionService.checkOrderDispatchEligibility(transactionId, invoiceNumber);
        }
        LOGGER.info("BILLED   txn {} PO {} {} -> {} : invoice {}, {} line(s), {} unit(s)", transactionId, plan.poNumber,
                plan.sourceWarehouseId, plan.destinationWarehouseId, invoiceNumber, plan.lines.size(),
                plan.lines.stream().mapToInt(x -> x.qty).sum());
        return invoiceNumber;
    }

    private Plan plan(int transactionId) throws ProfitMandiBusinessException {
        Plan plan = new Plan();
        plan.transactionId = transactionId;

        WarehousePurchaseOrder po = warehousePurchaseOrderRepository.selectByTransactionId(transactionId);
        if (po == null) {
            plan.problems.add("no purchase order is raised on transaction " + transactionId);
            return plan;
        }
        plan.poNumber = po.getPoNumber();
        plan.destinationWarehouseId = po.getWarehouseId();
        Supplier supplier = supplierRepository.selectById(po.getSupplierId());
        if (supplier == null || !supplier.isInternal() || supplier.getInternalWarehouseId() == null
                || supplier.getInternalWarehouseId() <= 0) {
            plan.problems.add("PO " + po.getPoNumber() + " is not an internal movement");
            return plan;
        }
        plan.sourceWarehouseId = supplier.getInternalWarehouseId();

        List<Order> orders = orderRepository.selectAllByTransactionIdAndWarehouseId(transactionId, plan.sourceWarehouseId)
                .stream().filter(x -> OrderStatus.SUBMITTED_FOR_PROCESSING.equals(x.getStatus()))
                .sorted(Comparator.comparing(Order::getId)).collect(Collectors.toList());
        if (orders.isEmpty()) {
            plan.problems.add("nothing left to bill on transaction " + transactionId + " (no order awaiting billing)");
            return plan;
        }

        List<Integer> itemIds = orders.stream().map(x -> x.getLineItem().getItemId()).distinct().collect(Collectors.toList());
        Map<Integer, InventoryWarehouse> warehouses = new HashMap<>();
        // free = what a vendor warehouse can still give to an order parked elsewhere
        Map<String, Integer> free = new HashMap<>();
        Map<String, Integer> available = new HashMap<>();
        Map<Integer, List<Integer>> realWarehousesByItem = new HashMap<>();
        for (SaholicInventorySnapshot snapshot : saholicInventoryService.getByWarehouseIdItemIds(plan.sourceWarehouseId, itemIds)) {
            if (!this.isRealVendorWarehouse(snapshot.getWarehouseId(), plan.sourceWarehouseId, warehouses)) {
                continue;
            }
            String key = key(snapshot.getWarehouseId(), snapshot.getItemId());
            available.put(key, snapshot.getAvailability());
            free.put(key, Math.max(snapshot.getAvailability() - snapshot.getReserved(), 0));
            realWarehousesByItem.computeIfAbsent(snapshot.getItemId(), x -> new ArrayList<>()).add(snapshot.getWarehouseId());
        }

        Map<String, List<String>> serialPools = new HashMap<>();
        Map<String, Integer> lotPools = new HashMap<>();
        for (Order order : orders) {
            Item item = order.getLineItem().getItem();
            int itemId = item.getId();
            int qty = order.getLineItem().getQuantity();
            boolean serialized = ItemType.SERIALIZED.equals(item.getType());
            String description = item.getItemDescription() + " (" + itemId + ")";

            // where the units come from: the order's own vendor warehouse, or - when parked - the ones with free stock
            Map<Integer, Integer> chunks = new LinkedHashMap<>();
            boolean repoint = !this.isRealVendorWarehouse(order.getFulfilmentWarehouseId(), plan.sourceWarehouseId, warehouses);
            if (!repoint) {
                int fulfilment = order.getFulfilmentWarehouseId();
                if (available.getOrDefault(key(fulfilment, itemId), 0) < qty) {
                    plan.problems.add("order " + order.getId() + " " + description + ": billing snapshot of warehouse "
                            + fulfilment + " shows " + available.getOrDefault(key(fulfilment, itemId), 0) + ", needs " + qty);
                    continue;
                }
                chunks.put(fulfilment, qty);
            } else {
                List<Integer> candidates = new ArrayList<>(realWarehousesByItem.getOrDefault(itemId, Collections.emptyList()));
                candidates.sort(Comparator.comparing((Integer w) -> free.getOrDefault(key(w, itemId), 0)).reversed());
                int remaining = qty;
                for (Integer warehouseId : candidates) {
                    int take = Math.min(remaining, free.getOrDefault(key(warehouseId, itemId), 0));
                    if (take > 0) {
                        chunks.put(warehouseId, take);
                        free.put(key(warehouseId, itemId), free.get(key(warehouseId, itemId)) - take);
                        remaining -= take;
                    }
                    if (remaining == 0) {
                        break;
                    }
                }
                if (remaining > 0) {
                    plan.problems.add("order " + order.getId() + " " + description + ": only " + (qty - remaining)
                            + " of " + qty + " free in the source's vendor warehouses");
                    continue;
                }
            }

            for (Map.Entry<Integer, Integer> chunk : chunks.entrySet()) {
                Line line = new Line();
                line.orderId = order.getId();
                line.itemId = itemId;
                line.item = description;
                line.qty = chunk.getValue();
                line.fromWarehouseId = chunk.getKey();
                line.repoint = repoint;
                line.serialized = serialized;
                String poolKey = key(chunk.getKey(), itemId);
                if (serialized) {
                    List<String> pool = serialPools.computeIfAbsent(poolKey, x -> this.billableImeis(itemId, chunk.getKey()));
                    if (pool.size() < line.qty) {
                        plan.problems.add("order " + order.getId() + " " + description + ": " + pool.size()
                                + " billable IMEI(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
                        continue;
                    }
                    line.imeis.addAll(pool.subList(0, line.qty));
                    pool.subList(0, line.qty).clear();
                } else {
                    int lots = lotPools.computeIfAbsent(poolKey, x -> this.billableLotQuantity(itemId, chunk.getKey()));
                    if (lots < line.qty) {
                        plan.problems.add("order " + order.getId() + " " + description + ": " + lots
                                + " billable unit(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
                        continue;
                    }
                    lotPools.put(poolKey, lots - line.qty);
                }
                plan.lines.add(line);
            }
        }
        if (plan.lines.size() > MAX_INVOICE_LINES) {
            plan.problems.add(plan.lines.size() + " invoice lines exceed the " + MAX_INVOICE_LINES
                    + " allowed on one IRN; raise the movement as smaller POs");
        }
        return plan;
    }

    // Billing takes an IMEI only at quantity 1 with a last scan that leaves it in stock (WarehouseServiceImpl billing).
    private List<String> billableImeis(int itemId, int vendorWarehouseId) {
        return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
                .filter(x -> x.getCurrentQuantity() == 1 && x.getSerialNumber() != null && !x.getSerialNumber().trim().isEmpty())
                .filter(x -> WarehouseServiceImpl.GOOD_SCAN_TYPES.contains(x.getLastScanType()))
                .sorted(Comparator.comparing(WarehouseInventoryItem::getCreated, Comparator.nullsFirst(Comparator.naturalOrder())))
                .map(WarehouseInventoryItem::getSerialNumber)
                .collect(Collectors.toCollection(ArrayList::new));
    }

    private int billableLotQuantity(int itemId, int vendorWarehouseId) {
        return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
                .filter(x -> x.getCurrentQuantity() > 0)
                .mapToInt(WarehouseInventoryItem::getCurrentQuantity).sum();
    }

    // A vendor warehouse the source bills from: its own OURS/GOOD stock, not the Dummy phantom target.
    private boolean isRealVendorWarehouse(Integer warehouseId, int sourceWarehouseId, Map<Integer, InventoryWarehouse> cache) {
        if (warehouseId == null) {
            return false;
        }
        InventoryWarehouse warehouse = cache.computeIfAbsent(warehouseId, inventoryWarehouseRepository::selectById);
        return warehouse != null && warehouse.getBillingWarehouseId() == sourceWarehouseId
                && warehouse.getVendorId() != ProfitMandiConstants.DEFAULT_FALLBACK_VENDOR_ID
                && InventoryType.GOOD.equals(warehouse.getInventoryType())
                && WarehouseType.OURS.equals(warehouse.getWarehouseType());
    }

    private static String key(int warehouseId, int itemId) {
        return warehouseId + ":" + itemId;
    }
}