Rev 37084 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed
package com.spice.profitmandi.web.v2.controller;import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;import com.spice.profitmandi.common.model.ProfitMandiConstants;import com.spice.profitmandi.common.model.UserInfo;import com.spice.profitmandi.dao.cart.CartService;import com.spice.profitmandi.dao.cart.v2.CartValidationService;import com.spice.profitmandi.dao.cart.v2.CheckoutValidationResult;import com.spice.profitmandi.dao.cart.v2.HydratedCart;import com.spice.profitmandi.dao.cart.v2.HydratedLine;import com.spice.profitmandi.dao.cart.v2.LineStatus;import com.spice.profitmandi.dao.cart.v2.OpenCartValidationResult;import com.spice.profitmandi.dao.cart.v2.PricingBreakup;import com.spice.profitmandi.dao.cart.v2.SaleType;import com.spice.profitmandi.dao.entity.catalog.Item;import com.spice.profitmandi.dao.entity.catalog.TagListing;import com.spice.profitmandi.dao.entity.fofo.PartnerType;import com.spice.profitmandi.dao.entity.user.CartLine;import com.spice.profitmandi.dao.enumuration.catalog.ByPassRequestStatus;import com.spice.profitmandi.dao.repository.fofo.PartnerTypeChangeService;import com.spice.profitmandi.dao.model.AddCartRequest;import com.spice.profitmandi.dao.model.CartItem;import com.spice.profitmandi.dao.model.UserCart;import com.spice.profitmandi.dao.repository.catalog.ItemRepository;import com.spice.profitmandi.dao.repository.catalog.TagListingRepository;import com.spice.profitmandi.dao.repository.dtr.UserAccountRepository;import com.spice.profitmandi.dao.repository.user.CartLineRepository;import com.spice.profitmandi.service.scheme.SchemeService;import com.spice.profitmandi.web.controller.CartController;import com.spice.profitmandi.web.v2.response.ApiResponse;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.*;import javax.servlet.http.HttpServletRequest;import java.math.BigDecimal;import java.math.RoundingMode;import java.time.LocalDate;import java.util.ArrayList;import java.util.Arrays;import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;import java.util.stream.Collectors;import org.springframework.transaction.annotation.Transactional;@RestController@RequestMapping("/v2")@Transactional(rollbackFor = Throwable.class)public class V2CartController extends V2BaseController {@Autowiredprivate CartController cartController;@Autowiredprivate CartValidationService cartValidationService;@Autowiredprivate UserAccountRepository userAccountRepository;@Autowiredprivate CartService cartService;@Autowiredprivate CartLineRepository cartLineRepository;@Autowiredprivate ItemRepository itemRepository;@Autowiredprivate TagListingRepository tagListingRepository;@Autowiredprivate SchemeService schemeService;@Autowiredprivate com.spice.profitmandi.dao.repository.user.CartRepository cartRepository;@Autowiredprivate PartnerTypeChangeService partnerTypeChangeService;private static final int MRP_TAG_ID = 4;private static final double CARRY_BAG_THRESHOLD = 12000d;// Premium tiers (Platinum/Diamond/Gold/Rising Star) get flat ₹1;// Silver/Bronze pay MRP from TagListing (matches CartServiceImpl.getCartValidation).private static final float CARRY_BAG_PREMIUM_PRICE = 1f;@GetMapping("/cart")public ResponseEntity<ApiResponse<?>> validateCart(HttpServletRequest request,@RequestParam(value = "pincode", defaultValue = "110001") String pincode,@RequestParam int bucketId) throws Throwable {return wrapResponse(cartController.validateCart(request, pincode, bucketId));}@PostMapping("/cart")public ResponseEntity<ApiResponse<?>> validateCart(HttpServletRequest request,@RequestBody AddCartRequest addCartRequest,@RequestParam(value = "pincode", defaultValue = "110001") String pincode) throws Throwable {return wrapResponse(cartController.validateCart(request, addCartRequest, pincode));}@PostMapping("/cart/changeAddress")public ResponseEntity<ApiResponse<?>> changeAddress(HttpServletRequest request,@RequestParam(value = "addressId") long addressId) throws Throwable {return wrapResponse(cartController.changeAddress(request, addressId));}@GetMapping("/byPassRequests")public ResponseEntity<ApiResponse<?>> byPassRequests(HttpServletRequest request, Model model) throws Throwable {return wrapResponse(cartController.byPassRequests(request, model));}@PostMapping("/byPassRequestAction")public ResponseEntity<ApiResponse<?>> addAmountToWalletRequestRejected(HttpServletRequest request,@RequestParam(name = "id", defaultValue = "0") int id,@RequestParam ByPassRequestStatus status,@RequestParam String reason,Model model) throws Throwable {return wrapResponse(cartController.addAmountToWalletRequestRejected(request, id, status, reason, model));}@GetMapping("/cart/payment")public ResponseEntity<ApiResponse<?>> validateCartPayment(HttpServletRequest request,@RequestParam(defaultValue = "0") int paymentId,Model model) throws Throwable {return wrapResponse(cartController.validateCartPayment(request, paymentId, model));}@GetMapping("/partner/hidAllocation")public ResponseEntity<ApiResponse<?>> getItemHidAllocation(HttpServletRequest request) throws Throwable {return wrapResponse(cartController.getItemHidAllocation(request));}// =================================================================// Cart v2 redesign — two-step validation (open + checkout)// All endpoints require authentication. Guests are rejected with 401.// =================================================================/** STEP 1 — soft validation. Never blocks. Surfaces warnings for price drift,* qty downgrade, OOS, unavailability. Refreshes last-seen price baseline* used by Step 2 drift detection.* Pincode is derived from the cart's bound address server-side; clients* do not pass it. */@GetMapping("/cart/open")public ResponseEntity<?> openCart(HttpServletRequest request)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();OpenCartValidationResult result = validateAndApplyCarryBagPrice(ctx.cartId, ctx.storeId);return ResponseEntity.ok(ApiResponse.success(result));}/** STEP 2 — hard validation immediately before payment. Returns* {@code valid=false} on any drift; user must hit {@code /cart/open}* to re-confirm, then retry this endpoint. On pass, creates a 240s* stock reservation and returns a {@code reservationId}.* Procurement only — tertiary billing drafts check out via* /v2/billing/drafts/{cartId}/commit. */@PostMapping("/cart/validate")public ResponseEntity<?> validateForCheckout(HttpServletRequest request,@RequestParam(value = "addressId") long addressId)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();CheckoutValidationResult result = cartValidationService.validateForCheckout(ctx.cartId, ctx.userId, ctx.storeId, addressId, SaleType.PARTNER_PROCUREMENT);return ResponseEntity.ok(ApiResponse.success(result));}// -----------------------------------------------------------------// Cart mutations — productId-only payload. All return the freshly// hydrated cart (Step 1 result) so the client never keeps stale state.// -----------------------------------------------------------------/** Add a single product to the cart. Increments qty if already present. */@PostMapping("/cart/items")public ResponseEntity<?> addItem(HttpServletRequest request,@RequestBody AddCartItemRequest body)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();if (body == null || body.productId <= 0 || body.quantity <= 0) {throw new ProfitMandiBusinessException("body", body, "CART_ITEM_INVALID_PAYLOAD");}List<CartItem> desired = currentLinesAsItems(ctx.cartId);boolean found = false;for (CartItem existing : desired) {if (existing.getItemId() == body.productId) {existing.setQuantity(existing.getQuantity() + body.quantity);found = true;break;}}if (!found) {desired.add(new CartItem(body.quantity, body.productId));}return applyAndHydrate(ctx, desired);}/** Set a specific line's quantity. quantity=0 removes the line. */@PostMapping("/cart/items/{productId}")public ResponseEntity<?> updateQuantity(HttpServletRequest request,@PathVariable int productId,@RequestParam int quantity)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();if (quantity < 0) {throw new ProfitMandiBusinessException("quantity", quantity, "CART_ITEM_NEGATIVE_QTY");}List<CartItem> desired = currentLinesAsItems(ctx.cartId);if (quantity == 0) {desired = desired.stream().filter(ci -> ci.getItemId() != productId).collect(Collectors.toList());} else {boolean found = false;for (CartItem existing : desired) {if (existing.getItemId() == productId) {existing.setQuantity(quantity);found = true;break;}}if (!found) {desired.add(new CartItem(quantity, productId));}}// Skip carry bag rebalance if user explicitly changed carry bag qtyif (productId == ProfitMandiConstants.ITEM_CARRY_BAG) {enrichPrices(ctx, desired);cartService.addItemsToCart(ctx.cartId, desired);return ResponseEntity.ok(ApiResponse.success(validateAndApplyCarryBagPrice(ctx.cartId, ctx.storeId)));}return applyAndHydrate(ctx, desired);}/** Remove a single line by productId. */@DeleteMapping("/cart/items/{productId}")public ResponseEntity<?> removeItem(HttpServletRequest request,@PathVariable int productId)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();List<CartItem> desired = currentLinesAsItems(ctx.cartId).stream().filter(ci -> ci.getItemId() != productId).collect(Collectors.toList());// Skip carry bag rebalance if user explicitly removed the carry bagif (productId == ProfitMandiConstants.ITEM_CARRY_BAG) {enrichPrices(ctx, desired);cartService.addItemsToCart(ctx.cartId, desired);return ResponseEntity.ok(ApiResponse.success(validateAndApplyCarryBagPrice(ctx.cartId, ctx.storeId)));}return applyAndHydrate(ctx, desired);}/** Clear the entire cart. */@DeleteMapping("/cart")public ResponseEntity<?> clearCart(HttpServletRequest request)throws ProfitMandiBusinessException {java.util.Optional<AuthCtx> auth = resolveAuth(request);if (!auth.isPresent()) return unauthorized();AuthCtx ctx = auth.get();cartService.clearCart(ctx.cartId);return ResponseEntity.ok(ApiResponse.success(validateAndApplyCarryBagPrice(ctx.cartId, ctx.storeId)));}/*** Enriches each desired CartItem with the live selling price, runs the* procurement carry-bag rebalance, applies the change, then returns a* hydrated Step-1 view. /v2/cart/* is procurement-only; tertiary drafts* have their own controller.*/private ResponseEntity<?> applyAndHydrate(AuthCtx ctx, List<CartItem> desired)throws ProfitMandiBusinessException {enrichPrices(ctx, desired);rebalanceCarryBag(desired, ctx.storeId);cartService.addItemsToCart(ctx.cartId, desired);return ResponseEntity.ok(ApiResponse.success(validateAndApplyCarryBagPrice(ctx.cartId, ctx.storeId)));}/*** Populates {@link CartItem#setSellingPrice(double)} for each non-carry-bag item* with the live (MOP - scheme cashback) figure. Preserves the existing value on* lookup miss so a partial-Solr outage doesn't zero out prices.*/private void enrichPrices(AuthCtx ctx, List<CartItem> desired) throws ProfitMandiBusinessException {Set<Integer> itemIds = desired.stream().filter(ci -> ci.getItemId() != ProfitMandiConstants.ITEM_CARRY_BAG).map(CartItem::getItemId).collect(Collectors.toSet());if (itemIds.isEmpty()) return;Map<Integer, TagListing> tagByItem = tagListingRepository.selectByItemIdsAndTagIds(itemIds, new HashSet<>(Arrays.asList(MRP_TAG_ID))).stream().collect(Collectors.toMap(TagListing::getItemId, x -> x, (a, b) -> a));Map<Integer, Item> items = itemRepository.selectByIds(itemIds).stream().collect(Collectors.toMap(Item::getId, x -> x, (a, b) -> a));List<Integer> catalogIds = items.values().stream().map(Item::getCatalogItemId).distinct().collect(Collectors.toList());Map<Integer, Float> cashbackByCatalog = schemeService.getCatalogSchemeCashBack(ctx.storeId, catalogIds);if (cashbackByCatalog == null) cashbackByCatalog = java.util.Collections.emptyMap();for (CartItem ci : desired) {if (ci.getItemId() == ProfitMandiConstants.ITEM_CARRY_BAG) continue;TagListing tl = tagByItem.get(ci.getItemId());Item it = items.get(ci.getItemId());if (tl == null || it == null) continue;float cashback = cashbackByCatalog.getOrDefault(it.getCatalogItemId(), 0f);ci.setSellingPrice(tl.getMop() - cashback);}}/*** Server-side port of the legacy Zustand carry-bag rule: any item with* sellingPrice > ₹12,000 needs one carry bag per unit. Runs BEFORE addItemsToCart* so the full-sync semantics keep the cart consistent in one round-trip.** Pricing mirrors {@code CartServiceImpl.getCartValidation} (lines 271-278):* PLATINUM / DIAMOND / GOLD / NEW (Rising Star) -> ₹1 flat* SILVER / BRONZE -> MRP from carry-bag TagListing*/private void rebalanceCarryBag(List<CartItem> desired, int fofoId) {int bagsNeeded = 0;for (CartItem ci : desired) {if (ci.getItemId() == ProfitMandiConstants.ITEM_CARRY_BAG) continue;if (ci.getSellingPrice() > CARRY_BAG_THRESHOLD) {bagsNeeded += ci.getQuantity();}}desired.removeIf(ci -> ci.getItemId() == ProfitMandiConstants.ITEM_CARRY_BAG);if (bagsNeeded > 0) {CartItem bag = new CartItem(bagsNeeded, ProfitMandiConstants.ITEM_CARRY_BAG);bag.setSellingPrice(resolveCarryBagPrice(fofoId));desired.add(bag);}}/*** Wraps {@link CartValidationService#validateForOpen} and overrides the* carry-bag line's LivePrice + rolls up totals using the tier logic that* v1's {@code CartServiceImpl.getCartValidation} already applies (lines* 271-278). Needed because the shared v2 hydrator prices every line from* {@code TagListing.getMop()} without a tier check, so premium partners* would otherwise see MRP (₹15) instead of ₹1.*/private OpenCartValidationResult validateAndApplyCarryBagPrice(int cartId, int fofoId)throws ProfitMandiBusinessException {OpenCartValidationResult result = cartValidationService.validateForOpen(cartId, fofoId);if (result == null || result.getCart() == null || result.getCart().getLines() == null) {return result;}HydratedCart cart = result.getCart();// 1. Overwrite every line's price from TagListing.sellingPrice — this is// the same source v1's CartServiceImpl.getCartValidation uses to set// cart_line.actual_price, i.e. the price the order will actually be// charged at. The v2 hydrator picks tagListing.mop instead, which// diverges from sellingPrice on scheme-discounted models (e.g. Moto// G67 showed ₹22,999 mop vs ₹19,907 sellingPrice). Sync client// display to server-of-truth so the partner sees what they'll pay.Set<Integer> lineItemIds = cart.getLines().stream().filter(l -> l.getProductId() != ProfitMandiConstants.ITEM_CARRY_BAG).map(HydratedLine::getProductId).collect(Collectors.toSet());Map<Integer, Float> sellingByItemId = new java.util.HashMap<>();if (!lineItemIds.isEmpty()) {List<TagListing> tls = tagListingRepository.selectByItemIdsAndTagIds(lineItemIds, new HashSet<>(Arrays.asList(MRP_TAG_ID)));for (TagListing tl : tls) {sellingByItemId.putIfAbsent(tl.getItemId(), tl.getSellingPrice());}}boolean overridden = false;for (HydratedLine line : cart.getLines()) {if (line.getProductId() == ProfitMandiConstants.ITEM_CARRY_BAG) continue;if (line.getPrice() == null) continue;Float livePrice = sellingByItemId.get(line.getProductId());if (livePrice == null) continue;BigDecimal newPrice = BigDecimal.valueOf(livePrice).setScale(2, RoundingMode.HALF_UP);if (line.getPrice().getSelling() == null|| line.getPrice().getSelling().compareTo(newPrice) != 0) {line.getPrice().setMrp(newPrice);line.getPrice().setSelling(newPrice);line.getPrice().setSchemeCashback(BigDecimal.ZERO);overridden = true;}}// 2. Carry-bag tier logic (₹1 for premium, MOP for Silver/Bronze).BigDecimal tierPrice = BigDecimal.valueOf(resolveCarryBagPrice(fofoId)).setScale(2, RoundingMode.HALF_UP);for (HydratedLine line : cart.getLines()) {if (line.getProductId() != ProfitMandiConstants.ITEM_CARRY_BAG) continue;if (line.getPrice() == null) continue;if (line.getPrice().getSelling() == null|| line.getPrice().getSelling().compareTo(tierPrice) != 0) {line.getPrice().setMrp(tierPrice);line.getPrice().setSelling(tierPrice);line.getPrice().setSchemeCashback(BigDecimal.ZERO);overridden = true;}}if (overridden && cart.getPricing() != null) {// Mirrors CartHydrationServiceImpl.computePricing so the grand total// reflects every overridden line price.BigDecimal subtotal = BigDecimal.ZERO;BigDecimal discounts = BigDecimal.ZERO;BigDecimal insuranceTotal = BigDecimal.ZERO;for (HydratedLine line : cart.getLines()) {if (line.getStatus() != LineStatus.ACTIVE || line.getPrice() == null) continue;BigDecimal qty = BigDecimal.valueOf(line.getQuantity());subtotal = subtotal.add(line.getPrice().getMrp().multiply(qty));BigDecimal cashback = line.getPrice().getSchemeCashback() == null? BigDecimal.ZERO : line.getPrice().getSchemeCashback();discounts = discounts.add(cashback.multiply(qty));if (line.getInsurance() != null && line.getInsurance().getPremium() != null) {insuranceTotal = insuranceTotal.add(line.getInsurance().getPremium());}}PricingBreakup p = cart.getPricing();p.setSubtotal(subtotal);p.setLineDiscounts(discounts);p.setTaxableBase(subtotal.subtract(discounts));p.setGrandTotal(p.getTaxableBase().add(insuranceTotal));}return result;}private float resolveCarryBagPrice(int fofoId) {PartnerType tier = null;try {tier = partnerTypeChangeService.getTypeOnDate(fofoId, LocalDate.now());} catch (Exception e) {// fall through to premium price}if (tier == PartnerType.SILVER || tier == PartnerType.BRONZE) {try {TagListing tl = tagListingRepository.selectByItemId(ProfitMandiConstants.ITEM_CARRY_BAG);if (tl != null) return tl.getSellingPrice();} catch (Exception e) {// fall through to premium price}}return CARRY_BAG_PREMIUM_PRICE;}private List<CartItem> currentLinesAsItems(int cartId) {List<CartLine> lines = cartLineRepository.selectAllByCart(cartId);if (lines == null) return new ArrayList<>();return lines.stream().map(l -> {CartItem ci = new CartItem(l.getQuantity(), l.getItemId());ci.setSellingPrice(l.getActualPrice());return ci;}).collect(Collectors.toList());}public static class AddCartItemRequest {public int productId;public int quantity;}// -----------------------------------------------------------------// Auth helpers — login is mandatory per product decision (2026-04-21).// Guest carts are not supported; unauthenticated requests get 401.//// Returns the auth context as an Optional rather than throwing, because// GlobalExceptionHandler's @ExceptionHandler(Exception.class) would// otherwise wrap any RuntimeException as HTTP 500 regardless of// @ResponseStatus annotations on the exception class.// -----------------------------------------------------------------private static final class AuthCtx {final int userId;final int storeId;final int cartId;AuthCtx(int userId, int storeId, int cartId) {this.userId = userId; this.storeId = storeId; this.cartId = cartId;}}/*** Resolves the authenticated partner's PROCUREMENT cart. v2/cart/* is* procurement-only. Tertiary billing drafts live under /v2/billing/* and* are resolved per-cartId there.*/private java.util.Optional<AuthCtx> resolveAuth(HttpServletRequest request) {Object userIdAttr = request.getAttribute("userId");if (userIdAttr == null) {return java.util.Optional.empty();}int userId;try {userId = (int) userIdAttr;} catch (ClassCastException e) {return java.util.Optional.empty();}if (userId <= 0) {return java.util.Optional.empty();}Object userInfoAttr = request.getAttribute("userInfo");int storeId = userInfoAttr instanceof UserInfo ? ((UserInfo) userInfoAttr).getRetailerId() : 0;UserCart uc = userAccountRepository.getUserCart(userId);if (uc == null || uc.getCartId() <= 0) {return java.util.Optional.empty();}return java.util.Optional.of(new AuthCtx(userId, storeId, uc.getCartId()));}private ResponseEntity<?> unauthorized() {java.util.Map<String, Object> body = new java.util.HashMap<>();body.put("responseStatus", "FAILURE");body.put("statusCode", 401);body.put("statusMessage", "LOGIN_REQUIRED");body.put("message", "Authentication is required. Send a valid Auth-Token header.");return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);}}