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.*/@Componentpublic 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;@Autowiredprivate WarehousePurchaseOrderRepository warehousePurchaseOrderRepository;@Autowiredprivate SupplierRepository supplierRepository;@Autowiredprivate OrderRepository orderRepository;@Autowiredprivate TransactionService transactionService;@Autowiredprivate SaholicInventoryService saholicInventoryService;@Autowiredprivate InventoryWarehouseRepository inventoryWarehouseRepository;@Autowiredprivate WarehouseInventoryItemRepository warehouseInventoryItemRepository;@Autowiredprivate 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 elsewhereMap<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 stockMap<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;}}