Subversion Repositories SmartDukaan

Rev

Rev 37825 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
37825 amit 1
package com.smartdukaan.cron.migrations;
2
 
3
import com.spice.profitmandi.common.enumuration.ItemType;
4
import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
5
import com.spice.profitmandi.common.model.ProfitMandiConstants;
6
import com.spice.profitmandi.dao.entity.auth.AuthUser;
7
import com.spice.profitmandi.dao.entity.catalog.Item;
8
import com.spice.profitmandi.dao.entity.inventory.InventoryWarehouse;
9
import com.spice.profitmandi.dao.entity.inventory.SaholicInventorySnapshot;
10
import com.spice.profitmandi.dao.entity.transaction.Order;
11
import com.spice.profitmandi.dao.entity.warehouse.Supplier;
12
import com.spice.profitmandi.dao.entity.warehouse.WarehouseInventoryItem;
13
import com.spice.profitmandi.dao.entity.warehouse.WarehousePurchaseOrder;
14
import com.spice.profitmandi.dao.model.transaction.OrderBillingModel;
15
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
16
import com.spice.profitmandi.dao.repository.inventory.InventoryWarehouseRepository;
17
import com.spice.profitmandi.dao.repository.transaction.OrderRepository;
18
import com.spice.profitmandi.dao.repository.warehouse.SupplierRepository;
19
import com.spice.profitmandi.dao.repository.warehouse.WarehouseInventoryItemRepository;
20
import com.spice.profitmandi.dao.repository.warehouse.WarehousePurchaseOrderRepository;
21
import com.spice.profitmandi.service.inventory.SaholicInventoryService;
22
import com.spice.profitmandi.service.transaction.TransactionService;
23
import com.spice.profitmandi.service.warehouse.WarehouseServiceImpl;
24
import in.shop2020.model.v1.inventory.InventoryType;
25
import in.shop2020.model.v1.inventory.WarehouseType;
26
import in.shop2020.model.v1.order.OrderStatus;
27
import org.apache.logging.log4j.LogManager;
28
import org.apache.logging.log4j.Logger;
29
import org.springframework.beans.factory.annotation.Autowired;
30
import org.springframework.stereotype.Component;
31
import org.springframework.transaction.annotation.Transactional;
32
 
33
import java.util.ArrayList;
34
import java.util.Collections;
35
import java.util.Comparator;
36
import java.util.HashMap;
37
import java.util.LinkedHashMap;
38
import java.util.List;
39
import java.util.Map;
40
import java.util.stream.Collectors;
41
 
42
/**
43
 * Bills the orders an internal movement PO raised, the way a biller does it on the order management screen: an order
44
 * parked on the Dummy (its brand has no source mapping) is first pointed at the vendor warehouse that holds the stock,
45
 * serialized units are taken oldest first from that vendor warehouse - only IMEIs whose last scan leaves them in stock -
46
 * and the whole transaction is billed as one invoice through acceptOrders / addBillingDetailsForGrouppedOrders.
47
 *
48
 * 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,
49
 * so nothing is billed unless every order of the transaction can be. The dry run makes no billing calls at all:
50
 * billing opens a REQUIRES_NEW transaction of its own (createLoanForBilling) that a rollback would not undo.
51
 */
52
@Component
53
public class MovementBiller {
54
 
55
    private static final Logger LOGGER = LogManager.getLogger(MovementBiller.class);
56
 
57
    // NIC rejects an IRN with more than 1000 item lines; one order is one line.
58
    private static final int MAX_INVOICE_LINES = 900;
59
 
60
    @Autowired
61
    private WarehousePurchaseOrderRepository warehousePurchaseOrderRepository;
62
    @Autowired
63
    private SupplierRepository supplierRepository;
64
    @Autowired
65
    private OrderRepository orderRepository;
66
    @Autowired
67
    private TransactionService transactionService;
68
    @Autowired
69
    private SaholicInventoryService saholicInventoryService;
70
    @Autowired
71
    private InventoryWarehouseRepository inventoryWarehouseRepository;
72
    @Autowired
73
    private WarehouseInventoryItemRepository warehouseInventoryItemRepository;
74
    @Autowired
75
    private AuthRepository authRepository;
76
 
77
    /** One invoice line: an order, the vendor warehouse it bills from and the IMEIs it takes. */
78
    static class Line {
79
        int orderId;
80
        int itemId;
81
        String item;
82
        int qty;
83
        int fromWarehouseId;
84
        boolean repoint;
85
        boolean serialized;
86
        List<String> imeis = new ArrayList<>();
87
    }
88
 
89
    static class Plan {
90
        int transactionId;
91
        String poNumber;
92
        int sourceWarehouseId;
93
        int destinationWarehouseId;
94
        List<Line> lines = new ArrayList<>();
95
        List<String> problems = new ArrayList<>();
96
    }
97
 
37828 amit 98
    /** The POs by number, in a session of their own - the billing driver carries no transaction. */
37825 amit 99
    @Transactional(readOnly = true, rollbackFor = Throwable.class)
37828 amit 100
    public List<WarehousePurchaseOrder> purchaseOrders(List<String> poNumbers) {
101
        return warehousePurchaseOrderRepository.selectAllByPONumbers(poNumbers);
102
    }
103
 
104
    @Transactional(readOnly = true, rollbackFor = Throwable.class)
37825 amit 105
    public Plan describe(int transactionId) throws ProfitMandiBusinessException {
106
        return this.plan(transactionId);
107
    }
108
 
109
    /** Bills the whole transaction or nothing. Returns the invoice number. */
110
    @Transactional(rollbackFor = Throwable.class)
111
    public String bill(int transactionId, String operatorEmail, int logisticsProviderId) throws Exception {
112
        AuthUser operator = authRepository.selectByEmailOrMobile(operatorEmail);
113
        if (operator == null) {
114
            throw new ProfitMandiBusinessException("email", operatorEmail, "No auth user for " + operatorEmail);
115
        }
116
        Plan plan = this.plan(transactionId);
117
        if (!plan.problems.isEmpty()) {
118
            throw new ProfitMandiBusinessException("transaction", transactionId, String.join("; ", plan.problems));
119
        }
120
 
121
        // Point Dummy-parked orders at the vendor warehouse holding the stock, splitting an order whose quantity
122
        // comes from more than one vendor warehouse. The remainder of a split keeps the Dummy until its own chunk.
123
        Map<Integer, Integer> remainderOf = new HashMap<>();
124
        for (Line line : plan.lines) {
125
            if (!line.repoint) {
126
                continue;
127
            }
128
            int orderId = remainderOf.getOrDefault(line.orderId, line.orderId);
129
            Order order = orderRepository.selectById(orderId);
130
            int orderQty = order.getLineItem().getQuantity();
131
            if (line.qty < orderQty) {
132
                Order remainder = transactionService.splitOrder(orderId, orderQty - line.qty);
133
                remainderOf.put(line.orderId, remainder.getId());
134
            }
135
            transactionService.changeFulfillmentWarehouse(orderId, line.fromWarehouseId);
136
            line.orderId = orderId;
137
        }
138
 
139
        List<OrderBillingModel> models = new ArrayList<>();
140
        for (Line line : plan.lines) {
141
            OrderBillingModel model = new OrderBillingModel();
142
            model.setOrderId(line.orderId);
143
            model.setQuantityToBill(line.qty);
144
            model.setSerialNumbersToBill(line.imeis.stream().map(String::toLowerCase).collect(Collectors.toList()));
145
            model.setLogisticsProviderId(logisticsProviderId);
146
            model.setDistance("");
147
            models.add(model);
148
        }
149
        transactionService.acceptOrders(models);
150
        String invoiceNumber = transactionService.addBillingDetailsForGrouppedOrders(models, plan.sourceWarehouseId,
151
                operator.getFirstName());
152
        if (invoiceNumber != null) {
153
            transactionService.checkOrderDispatchEligibility(transactionId, invoiceNumber);
154
        }
155
        LOGGER.info("BILLED   txn {} PO {} {} -> {} : invoice {}, {} line(s), {} unit(s)", transactionId, plan.poNumber,
156
                plan.sourceWarehouseId, plan.destinationWarehouseId, invoiceNumber, plan.lines.size(),
157
                plan.lines.stream().mapToInt(x -> x.qty).sum());
158
        return invoiceNumber;
159
    }
160
 
161
    private Plan plan(int transactionId) throws ProfitMandiBusinessException {
162
        Plan plan = new Plan();
163
        plan.transactionId = transactionId;
164
 
165
        WarehousePurchaseOrder po = warehousePurchaseOrderRepository.selectByTransactionId(transactionId);
166
        if (po == null) {
167
            plan.problems.add("no purchase order is raised on transaction " + transactionId);
168
            return plan;
169
        }
170
        plan.poNumber = po.getPoNumber();
171
        plan.destinationWarehouseId = po.getWarehouseId();
172
        Supplier supplier = supplierRepository.selectById(po.getSupplierId());
173
        if (supplier == null || !supplier.isInternal() || supplier.getInternalWarehouseId() == null
174
                || supplier.getInternalWarehouseId() <= 0) {
175
            plan.problems.add("PO " + po.getPoNumber() + " is not an internal movement");
176
            return plan;
177
        }
178
        plan.sourceWarehouseId = supplier.getInternalWarehouseId();
179
 
180
        List<Order> orders = orderRepository.selectAllByTransactionIdAndWarehouseId(transactionId, plan.sourceWarehouseId)
181
                .stream().filter(x -> OrderStatus.SUBMITTED_FOR_PROCESSING.equals(x.getStatus()))
182
                .sorted(Comparator.comparing(Order::getId)).collect(Collectors.toList());
183
        if (orders.isEmpty()) {
184
            plan.problems.add("nothing left to bill on transaction " + transactionId + " (no order awaiting billing)");
185
            return plan;
186
        }
187
 
188
        List<Integer> itemIds = orders.stream().map(x -> x.getLineItem().getItemId()).distinct().collect(Collectors.toList());
189
        Map<Integer, InventoryWarehouse> warehouses = new HashMap<>();
190
        // free = what a vendor warehouse can still give to an order parked elsewhere
191
        Map<String, Integer> free = new HashMap<>();
192
        Map<String, Integer> available = new HashMap<>();
193
        Map<Integer, List<Integer>> realWarehousesByItem = new HashMap<>();
194
        for (SaholicInventorySnapshot snapshot : saholicInventoryService.getByWarehouseIdItemIds(plan.sourceWarehouseId, itemIds)) {
195
            if (!this.isRealVendorWarehouse(snapshot.getWarehouseId(), plan.sourceWarehouseId, warehouses)) {
196
                continue;
197
            }
198
            String key = key(snapshot.getWarehouseId(), snapshot.getItemId());
199
            available.put(key, snapshot.getAvailability());
200
            free.put(key, Math.max(snapshot.getAvailability() - snapshot.getReserved(), 0));
201
            realWarehousesByItem.computeIfAbsent(snapshot.getItemId(), x -> new ArrayList<>()).add(snapshot.getWarehouseId());
202
        }
203
 
204
        Map<String, List<String>> serialPools = new HashMap<>();
205
        Map<String, Integer> lotPools = new HashMap<>();
206
        for (Order order : orders) {
207
            Item item = order.getLineItem().getItem();
208
            int itemId = item.getId();
209
            int qty = order.getLineItem().getQuantity();
210
            boolean serialized = ItemType.SERIALIZED.equals(item.getType());
211
            String description = item.getItemDescription() + " (" + itemId + ")";
212
 
213
            // where the units come from: the order's own vendor warehouse, or - when parked - the ones with free stock
214
            Map<Integer, Integer> chunks = new LinkedHashMap<>();
215
            boolean repoint = !this.isRealVendorWarehouse(order.getFulfilmentWarehouseId(), plan.sourceWarehouseId, warehouses);
216
            if (!repoint) {
217
                int fulfilment = order.getFulfilmentWarehouseId();
218
                if (available.getOrDefault(key(fulfilment, itemId), 0) < qty) {
219
                    plan.problems.add("order " + order.getId() + " " + description + ": billing snapshot of warehouse "
220
                            + fulfilment + " shows " + available.getOrDefault(key(fulfilment, itemId), 0) + ", needs " + qty);
221
                    continue;
222
                }
223
                chunks.put(fulfilment, qty);
224
            } else {
225
                List<Integer> candidates = new ArrayList<>(realWarehousesByItem.getOrDefault(itemId, Collections.emptyList()));
226
                candidates.sort(Comparator.comparing((Integer w) -> free.getOrDefault(key(w, itemId), 0)).reversed());
227
                int remaining = qty;
228
                for (Integer warehouseId : candidates) {
229
                    int take = Math.min(remaining, free.getOrDefault(key(warehouseId, itemId), 0));
230
                    if (take > 0) {
231
                        chunks.put(warehouseId, take);
232
                        free.put(key(warehouseId, itemId), free.get(key(warehouseId, itemId)) - take);
233
                        remaining -= take;
234
                    }
235
                    if (remaining == 0) {
236
                        break;
237
                    }
238
                }
239
                if (remaining > 0) {
240
                    plan.problems.add("order " + order.getId() + " " + description + ": only " + (qty - remaining)
241
                            + " of " + qty + " free in the source's vendor warehouses");
242
                    continue;
243
                }
244
            }
245
 
246
            for (Map.Entry<Integer, Integer> chunk : chunks.entrySet()) {
247
                Line line = new Line();
248
                line.orderId = order.getId();
249
                line.itemId = itemId;
250
                line.item = description;
251
                line.qty = chunk.getValue();
252
                line.fromWarehouseId = chunk.getKey();
253
                line.repoint = repoint;
254
                line.serialized = serialized;
255
                String poolKey = key(chunk.getKey(), itemId);
256
                if (serialized) {
257
                    List<String> pool = serialPools.computeIfAbsent(poolKey, x -> this.billableImeis(itemId, chunk.getKey()));
258
                    if (pool.size() < line.qty) {
259
                        plan.problems.add("order " + order.getId() + " " + description + ": " + pool.size()
260
                                + " billable IMEI(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
261
                        continue;
262
                    }
263
                    line.imeis.addAll(pool.subList(0, line.qty));
264
                    pool.subList(0, line.qty).clear();
265
                } else {
266
                    int lots = lotPools.computeIfAbsent(poolKey, x -> this.billableLotQuantity(itemId, chunk.getKey()));
267
                    if (lots < line.qty) {
268
                        plan.problems.add("order " + order.getId() + " " + description + ": " + lots
269
                                + " billable unit(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
270
                        continue;
271
                    }
272
                    lotPools.put(poolKey, lots - line.qty);
273
                }
274
                plan.lines.add(line);
275
            }
276
        }
277
        if (plan.lines.size() > MAX_INVOICE_LINES) {
278
            plan.problems.add(plan.lines.size() + " invoice lines exceed the " + MAX_INVOICE_LINES
279
                    + " allowed on one IRN; raise the movement as smaller POs");
280
        }
281
        return plan;
282
    }
283
 
284
    // Billing takes an IMEI only at quantity 1 with a last scan that leaves it in stock (WarehouseServiceImpl billing).
285
    private List<String> billableImeis(int itemId, int vendorWarehouseId) {
286
        return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
287
                .filter(x -> x.getCurrentQuantity() == 1 && x.getSerialNumber() != null && !x.getSerialNumber().trim().isEmpty())
288
                .filter(x -> WarehouseServiceImpl.GOOD_SCAN_TYPES.contains(x.getLastScanType()))
289
                .sorted(Comparator.comparing(WarehouseInventoryItem::getCreated, Comparator.nullsFirst(Comparator.naturalOrder())))
290
                .map(WarehouseInventoryItem::getSerialNumber)
291
                .collect(Collectors.toCollection(ArrayList::new));
292
    }
293
 
294
    private int billableLotQuantity(int itemId, int vendorWarehouseId) {
295
        return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
296
                .filter(x -> x.getCurrentQuantity() > 0)
297
                .mapToInt(WarehouseInventoryItem::getCurrentQuantity).sum();
298
    }
299
 
300
    // A vendor warehouse the source bills from: its own OURS/GOOD stock, not the Dummy phantom target.
301
    private boolean isRealVendorWarehouse(Integer warehouseId, int sourceWarehouseId, Map<Integer, InventoryWarehouse> cache) {
302
        if (warehouseId == null) {
303
            return false;
304
        }
305
        InventoryWarehouse warehouse = cache.computeIfAbsent(warehouseId, inventoryWarehouseRepository::selectById);
306
        return warehouse != null && warehouse.getBillingWarehouseId() == sourceWarehouseId
307
                && warehouse.getVendorId() != ProfitMandiConstants.DEFAULT_FALLBACK_VENDOR_ID
308
                && InventoryType.GOOD.equals(warehouse.getInventoryType())
309
                && WarehouseType.OURS.equals(warehouse.getWarehouseType());
310
    }
311
 
312
    private static String key(int warehouseId, int itemId) {
313
        return warehouseId + ":" + itemId;
314
    }
315
}