| 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 |
|
|
|
98 |
@Transactional(readOnly = true, rollbackFor = Throwable.class)
|
|
|
99 |
public Plan describe(int transactionId) throws ProfitMandiBusinessException {
|
|
|
100 |
return this.plan(transactionId);
|
|
|
101 |
}
|
|
|
102 |
|
|
|
103 |
/** Bills the whole transaction or nothing. Returns the invoice number. */
|
|
|
104 |
@Transactional(rollbackFor = Throwable.class)
|
|
|
105 |
public String bill(int transactionId, String operatorEmail, int logisticsProviderId) throws Exception {
|
|
|
106 |
AuthUser operator = authRepository.selectByEmailOrMobile(operatorEmail);
|
|
|
107 |
if (operator == null) {
|
|
|
108 |
throw new ProfitMandiBusinessException("email", operatorEmail, "No auth user for " + operatorEmail);
|
|
|
109 |
}
|
|
|
110 |
Plan plan = this.plan(transactionId);
|
|
|
111 |
if (!plan.problems.isEmpty()) {
|
|
|
112 |
throw new ProfitMandiBusinessException("transaction", transactionId, String.join("; ", plan.problems));
|
|
|
113 |
}
|
|
|
114 |
|
|
|
115 |
// Point Dummy-parked orders at the vendor warehouse holding the stock, splitting an order whose quantity
|
|
|
116 |
// comes from more than one vendor warehouse. The remainder of a split keeps the Dummy until its own chunk.
|
|
|
117 |
Map<Integer, Integer> remainderOf = new HashMap<>();
|
|
|
118 |
for (Line line : plan.lines) {
|
|
|
119 |
if (!line.repoint) {
|
|
|
120 |
continue;
|
|
|
121 |
}
|
|
|
122 |
int orderId = remainderOf.getOrDefault(line.orderId, line.orderId);
|
|
|
123 |
Order order = orderRepository.selectById(orderId);
|
|
|
124 |
int orderQty = order.getLineItem().getQuantity();
|
|
|
125 |
if (line.qty < orderQty) {
|
|
|
126 |
Order remainder = transactionService.splitOrder(orderId, orderQty - line.qty);
|
|
|
127 |
remainderOf.put(line.orderId, remainder.getId());
|
|
|
128 |
}
|
|
|
129 |
transactionService.changeFulfillmentWarehouse(orderId, line.fromWarehouseId);
|
|
|
130 |
line.orderId = orderId;
|
|
|
131 |
}
|
|
|
132 |
|
|
|
133 |
List<OrderBillingModel> models = new ArrayList<>();
|
|
|
134 |
for (Line line : plan.lines) {
|
|
|
135 |
OrderBillingModel model = new OrderBillingModel();
|
|
|
136 |
model.setOrderId(line.orderId);
|
|
|
137 |
model.setQuantityToBill(line.qty);
|
|
|
138 |
model.setSerialNumbersToBill(line.imeis.stream().map(String::toLowerCase).collect(Collectors.toList()));
|
|
|
139 |
model.setLogisticsProviderId(logisticsProviderId);
|
|
|
140 |
model.setDistance("");
|
|
|
141 |
models.add(model);
|
|
|
142 |
}
|
|
|
143 |
transactionService.acceptOrders(models);
|
|
|
144 |
String invoiceNumber = transactionService.addBillingDetailsForGrouppedOrders(models, plan.sourceWarehouseId,
|
|
|
145 |
operator.getFirstName());
|
|
|
146 |
if (invoiceNumber != null) {
|
|
|
147 |
transactionService.checkOrderDispatchEligibility(transactionId, invoiceNumber);
|
|
|
148 |
}
|
|
|
149 |
LOGGER.info("BILLED txn {} PO {} {} -> {} : invoice {}, {} line(s), {} unit(s)", transactionId, plan.poNumber,
|
|
|
150 |
plan.sourceWarehouseId, plan.destinationWarehouseId, invoiceNumber, plan.lines.size(),
|
|
|
151 |
plan.lines.stream().mapToInt(x -> x.qty).sum());
|
|
|
152 |
return invoiceNumber;
|
|
|
153 |
}
|
|
|
154 |
|
|
|
155 |
private Plan plan(int transactionId) throws ProfitMandiBusinessException {
|
|
|
156 |
Plan plan = new Plan();
|
|
|
157 |
plan.transactionId = transactionId;
|
|
|
158 |
|
|
|
159 |
WarehousePurchaseOrder po = warehousePurchaseOrderRepository.selectByTransactionId(transactionId);
|
|
|
160 |
if (po == null) {
|
|
|
161 |
plan.problems.add("no purchase order is raised on transaction " + transactionId);
|
|
|
162 |
return plan;
|
|
|
163 |
}
|
|
|
164 |
plan.poNumber = po.getPoNumber();
|
|
|
165 |
plan.destinationWarehouseId = po.getWarehouseId();
|
|
|
166 |
Supplier supplier = supplierRepository.selectById(po.getSupplierId());
|
|
|
167 |
if (supplier == null || !supplier.isInternal() || supplier.getInternalWarehouseId() == null
|
|
|
168 |
|| supplier.getInternalWarehouseId() <= 0) {
|
|
|
169 |
plan.problems.add("PO " + po.getPoNumber() + " is not an internal movement");
|
|
|
170 |
return plan;
|
|
|
171 |
}
|
|
|
172 |
plan.sourceWarehouseId = supplier.getInternalWarehouseId();
|
|
|
173 |
|
|
|
174 |
List<Order> orders = orderRepository.selectAllByTransactionIdAndWarehouseId(transactionId, plan.sourceWarehouseId)
|
|
|
175 |
.stream().filter(x -> OrderStatus.SUBMITTED_FOR_PROCESSING.equals(x.getStatus()))
|
|
|
176 |
.sorted(Comparator.comparing(Order::getId)).collect(Collectors.toList());
|
|
|
177 |
if (orders.isEmpty()) {
|
|
|
178 |
plan.problems.add("nothing left to bill on transaction " + transactionId + " (no order awaiting billing)");
|
|
|
179 |
return plan;
|
|
|
180 |
}
|
|
|
181 |
|
|
|
182 |
List<Integer> itemIds = orders.stream().map(x -> x.getLineItem().getItemId()).distinct().collect(Collectors.toList());
|
|
|
183 |
Map<Integer, InventoryWarehouse> warehouses = new HashMap<>();
|
|
|
184 |
// free = what a vendor warehouse can still give to an order parked elsewhere
|
|
|
185 |
Map<String, Integer> free = new HashMap<>();
|
|
|
186 |
Map<String, Integer> available = new HashMap<>();
|
|
|
187 |
Map<Integer, List<Integer>> realWarehousesByItem = new HashMap<>();
|
|
|
188 |
for (SaholicInventorySnapshot snapshot : saholicInventoryService.getByWarehouseIdItemIds(plan.sourceWarehouseId, itemIds)) {
|
|
|
189 |
if (!this.isRealVendorWarehouse(snapshot.getWarehouseId(), plan.sourceWarehouseId, warehouses)) {
|
|
|
190 |
continue;
|
|
|
191 |
}
|
|
|
192 |
String key = key(snapshot.getWarehouseId(), snapshot.getItemId());
|
|
|
193 |
available.put(key, snapshot.getAvailability());
|
|
|
194 |
free.put(key, Math.max(snapshot.getAvailability() - snapshot.getReserved(), 0));
|
|
|
195 |
realWarehousesByItem.computeIfAbsent(snapshot.getItemId(), x -> new ArrayList<>()).add(snapshot.getWarehouseId());
|
|
|
196 |
}
|
|
|
197 |
|
|
|
198 |
Map<String, List<String>> serialPools = new HashMap<>();
|
|
|
199 |
Map<String, Integer> lotPools = new HashMap<>();
|
|
|
200 |
for (Order order : orders) {
|
|
|
201 |
Item item = order.getLineItem().getItem();
|
|
|
202 |
int itemId = item.getId();
|
|
|
203 |
int qty = order.getLineItem().getQuantity();
|
|
|
204 |
boolean serialized = ItemType.SERIALIZED.equals(item.getType());
|
|
|
205 |
String description = item.getItemDescription() + " (" + itemId + ")";
|
|
|
206 |
|
|
|
207 |
// where the units come from: the order's own vendor warehouse, or - when parked - the ones with free stock
|
|
|
208 |
Map<Integer, Integer> chunks = new LinkedHashMap<>();
|
|
|
209 |
boolean repoint = !this.isRealVendorWarehouse(order.getFulfilmentWarehouseId(), plan.sourceWarehouseId, warehouses);
|
|
|
210 |
if (!repoint) {
|
|
|
211 |
int fulfilment = order.getFulfilmentWarehouseId();
|
|
|
212 |
if (available.getOrDefault(key(fulfilment, itemId), 0) < qty) {
|
|
|
213 |
plan.problems.add("order " + order.getId() + " " + description + ": billing snapshot of warehouse "
|
|
|
214 |
+ fulfilment + " shows " + available.getOrDefault(key(fulfilment, itemId), 0) + ", needs " + qty);
|
|
|
215 |
continue;
|
|
|
216 |
}
|
|
|
217 |
chunks.put(fulfilment, qty);
|
|
|
218 |
} else {
|
|
|
219 |
List<Integer> candidates = new ArrayList<>(realWarehousesByItem.getOrDefault(itemId, Collections.emptyList()));
|
|
|
220 |
candidates.sort(Comparator.comparing((Integer w) -> free.getOrDefault(key(w, itemId), 0)).reversed());
|
|
|
221 |
int remaining = qty;
|
|
|
222 |
for (Integer warehouseId : candidates) {
|
|
|
223 |
int take = Math.min(remaining, free.getOrDefault(key(warehouseId, itemId), 0));
|
|
|
224 |
if (take > 0) {
|
|
|
225 |
chunks.put(warehouseId, take);
|
|
|
226 |
free.put(key(warehouseId, itemId), free.get(key(warehouseId, itemId)) - take);
|
|
|
227 |
remaining -= take;
|
|
|
228 |
}
|
|
|
229 |
if (remaining == 0) {
|
|
|
230 |
break;
|
|
|
231 |
}
|
|
|
232 |
}
|
|
|
233 |
if (remaining > 0) {
|
|
|
234 |
plan.problems.add("order " + order.getId() + " " + description + ": only " + (qty - remaining)
|
|
|
235 |
+ " of " + qty + " free in the source's vendor warehouses");
|
|
|
236 |
continue;
|
|
|
237 |
}
|
|
|
238 |
}
|
|
|
239 |
|
|
|
240 |
for (Map.Entry<Integer, Integer> chunk : chunks.entrySet()) {
|
|
|
241 |
Line line = new Line();
|
|
|
242 |
line.orderId = order.getId();
|
|
|
243 |
line.itemId = itemId;
|
|
|
244 |
line.item = description;
|
|
|
245 |
line.qty = chunk.getValue();
|
|
|
246 |
line.fromWarehouseId = chunk.getKey();
|
|
|
247 |
line.repoint = repoint;
|
|
|
248 |
line.serialized = serialized;
|
|
|
249 |
String poolKey = key(chunk.getKey(), itemId);
|
|
|
250 |
if (serialized) {
|
|
|
251 |
List<String> pool = serialPools.computeIfAbsent(poolKey, x -> this.billableImeis(itemId, chunk.getKey()));
|
|
|
252 |
if (pool.size() < line.qty) {
|
|
|
253 |
plan.problems.add("order " + order.getId() + " " + description + ": " + pool.size()
|
|
|
254 |
+ " billable IMEI(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
|
|
|
255 |
continue;
|
|
|
256 |
}
|
|
|
257 |
line.imeis.addAll(pool.subList(0, line.qty));
|
|
|
258 |
pool.subList(0, line.qty).clear();
|
|
|
259 |
} else {
|
|
|
260 |
int lots = lotPools.computeIfAbsent(poolKey, x -> this.billableLotQuantity(itemId, chunk.getKey()));
|
|
|
261 |
if (lots < line.qty) {
|
|
|
262 |
plan.problems.add("order " + order.getId() + " " + description + ": " + lots
|
|
|
263 |
+ " billable unit(s) in warehouse " + chunk.getKey() + ", needs " + line.qty);
|
|
|
264 |
continue;
|
|
|
265 |
}
|
|
|
266 |
lotPools.put(poolKey, lots - line.qty);
|
|
|
267 |
}
|
|
|
268 |
plan.lines.add(line);
|
|
|
269 |
}
|
|
|
270 |
}
|
|
|
271 |
if (plan.lines.size() > MAX_INVOICE_LINES) {
|
|
|
272 |
plan.problems.add(plan.lines.size() + " invoice lines exceed the " + MAX_INVOICE_LINES
|
|
|
273 |
+ " allowed on one IRN; raise the movement as smaller POs");
|
|
|
274 |
}
|
|
|
275 |
return plan;
|
|
|
276 |
}
|
|
|
277 |
|
|
|
278 |
// Billing takes an IMEI only at quantity 1 with a last scan that leaves it in stock (WarehouseServiceImpl billing).
|
|
|
279 |
private List<String> billableImeis(int itemId, int vendorWarehouseId) {
|
|
|
280 |
return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
|
|
|
281 |
.filter(x -> x.getCurrentQuantity() == 1 && x.getSerialNumber() != null && !x.getSerialNumber().trim().isEmpty())
|
|
|
282 |
.filter(x -> WarehouseServiceImpl.GOOD_SCAN_TYPES.contains(x.getLastScanType()))
|
|
|
283 |
.sorted(Comparator.comparing(WarehouseInventoryItem::getCreated, Comparator.nullsFirst(Comparator.naturalOrder())))
|
|
|
284 |
.map(WarehouseInventoryItem::getSerialNumber)
|
|
|
285 |
.collect(Collectors.toCollection(ArrayList::new));
|
|
|
286 |
}
|
|
|
287 |
|
|
|
288 |
private int billableLotQuantity(int itemId, int vendorWarehouseId) {
|
|
|
289 |
return warehouseInventoryItemRepository.selectBillableStock(itemId, vendorWarehouseId).stream()
|
|
|
290 |
.filter(x -> x.getCurrentQuantity() > 0)
|
|
|
291 |
.mapToInt(WarehouseInventoryItem::getCurrentQuantity).sum();
|
|
|
292 |
}
|
|
|
293 |
|
|
|
294 |
// A vendor warehouse the source bills from: its own OURS/GOOD stock, not the Dummy phantom target.
|
|
|
295 |
private boolean isRealVendorWarehouse(Integer warehouseId, int sourceWarehouseId, Map<Integer, InventoryWarehouse> cache) {
|
|
|
296 |
if (warehouseId == null) {
|
|
|
297 |
return false;
|
|
|
298 |
}
|
|
|
299 |
InventoryWarehouse warehouse = cache.computeIfAbsent(warehouseId, inventoryWarehouseRepository::selectById);
|
|
|
300 |
return warehouse != null && warehouse.getBillingWarehouseId() == sourceWarehouseId
|
|
|
301 |
&& warehouse.getVendorId() != ProfitMandiConstants.DEFAULT_FALLBACK_VENDOR_ID
|
|
|
302 |
&& InventoryType.GOOD.equals(warehouse.getInventoryType())
|
|
|
303 |
&& WarehouseType.OURS.equals(warehouse.getWarehouseType());
|
|
|
304 |
}
|
|
|
305 |
|
|
|
306 |
private static String key(int warehouseId, int itemId) {
|
|
|
307 |
return warehouseId + ":" + itemId;
|
|
|
308 |
}
|
|
|
309 |
}
|