| Line 49... |
Line 49... |
| 49 |
if (activeOffers.isEmpty()) {
|
49 |
if (activeOffers.isEmpty()) {
|
| 50 |
return;
|
50 |
return;
|
| 51 |
}
|
51 |
}
|
| 52 |
|
52 |
|
| 53 |
FofoOrder fofoOrder = fofoOrderRepository.selectByOrderId(fofoOrderId);
|
53 |
FofoOrder fofoOrder = fofoOrderRepository.selectByOrderId(fofoOrderId);
|
| - |
|
54 |
|
| - |
|
55 |
// --- V2: offer selection and the per-offer availed check now happen inside processOfferV2 ---
|
| 54 |
OfferEntity eligibleOffer = findEligibleOffer(activeOffers, fofoOrder);
|
56 |
// OfferEntity eligibleOffer = findEligibleOffer(activeOffers, fofoOrder);
|
| 55 |
if (eligibleOffer == null) {
|
57 |
// if (eligibleOffer == null) {
|
| 56 |
logger.info("No eligible offer for order {}", fofoOrderId);
|
58 |
// logger.info("No eligible offer for order {}", fofoOrderId);
|
| - |
|
59 |
// return;
|
| - |
|
60 |
// }
|
| - |
|
61 |
//
|
| - |
|
62 |
// ScratchOffer availedOffer = scratchOfferRepository.getScractchOfferByCustomerIdAndOfferId(fofoOrder.getCustomerId(), eligibleOffer.getId());
|
| - |
|
63 |
// if (availedOffer != null) {
|
| - |
|
64 |
// logger.info("Customer {} already availed offer {}", fofoOrder.getCustomerId(), eligibleOffer.getId());
|
| - |
|
65 |
// return;
|
| - |
|
66 |
// }
|
| - |
|
67 |
//
|
| - |
|
68 |
// this.processOffer(eligibleOffer, fofoOrder);
|
| - |
|
69 |
|
| - |
|
70 |
this.processOfferV2(activeOffers, fofoOrder);
|
| - |
|
71 |
}
|
| - |
|
72 |
|
| - |
|
73 |
// ------------------------------------------------------------------------------------------------
|
| - |
|
74 |
// V2 award flow. Order value picks the slab, a random in-stock gift wins, and the offer's default
|
| - |
|
75 |
// gift is the floor. A gift carrying a partner list (fofo_store) is a partner-restricted special and
|
| - |
|
76 |
// is awarded to those partners on bills above SPECIAL_GIFT_MIN_ORDER_VALUE; a gift with no partner
|
| - |
|
77 |
// list belongs to the random pool.
|
| - |
|
78 |
//
|
| - |
|
79 |
// Deliberately bypasses the V1 gating: no per-day user limit, no Apple override, no item-category
|
| - |
|
80 |
// filter, no payment-method filter, no classification switch. The one-gift-per-customer-per-offer
|
| - |
|
81 |
// rule is kept, applied per offer across every active offer.
|
| - |
|
82 |
//
|
| - |
|
83 |
// Nothing below calls a V1 helper, so this whole block can be deleted to roll back.
|
| - |
|
84 |
// ------------------------------------------------------------------------------------------------
|
| - |
|
85 |
|
| - |
|
86 |
private static final double SPECIAL_GIFT_MIN_ORDER_VALUE = 25000d;
|
| - |
|
87 |
|
| - |
|
88 |
private void processOfferV2(List<OfferEntity> activeOffers, FofoOrder fofoOrder) {
|
| - |
|
89 |
if (fofoOrder == null) {
|
| - |
|
90 |
logger.error("Order not found, skipping scratch award");
|
| - |
|
91 |
return;
|
| - |
|
92 |
}
|
| - |
|
93 |
|
| - |
|
94 |
List<OfferEntity> eligibleOffers = filterOffersNotAvailed(activeOffers, fofoOrder.getCustomerId());
|
| - |
|
95 |
if (eligibleOffers.isEmpty()) {
|
| - |
|
96 |
logger.info("Customer {} has already availed every active offer", fofoOrder.getCustomerId());
|
| - |
|
97 |
return;
|
| - |
|
98 |
}
|
| - |
|
99 |
|
| - |
|
100 |
Map<Integer, OfferEntity> offerById = new HashMap<>();
|
| - |
|
101 |
List<GiftEntity> pool = new ArrayList<>();
|
| - |
|
102 |
for (OfferEntity offer : eligibleOffers) {
|
| - |
|
103 |
offerById.put(offer.getId(), offer);
|
| - |
|
104 |
List<GiftEntity> gifts = giftRepository.findByOfferId(offer.getId());
|
| - |
|
105 |
if (gifts != null) {
|
| - |
|
106 |
pool.addAll(gifts);
|
| - |
|
107 |
}
|
| - |
|
108 |
}
|
| - |
|
109 |
if (pool.isEmpty()) {
|
| - |
|
110 |
logger.warn("No gifts configured on any active offer, order {} gets no scratch card", fofoOrder.getId());
|
| - |
|
111 |
return;
|
| - |
|
112 |
}
|
| - |
|
113 |
|
| - |
|
114 |
double amount = fofoOrder.getTotalAmount();
|
| - |
|
115 |
|
| - |
|
116 |
// 1. Partner-restricted special gift
|
| - |
|
117 |
GiftEntity special = findSpecialPartnerGift(pool, fofoOrder.getFofoId(), amount);
|
| - |
|
118 |
if (special != null && awardWithStock(fofoOrder, offerById.get(special.getOfferId()), special)) {
|
| 57 |
return;
|
119 |
return;
|
| 58 |
}
|
120 |
}
|
| 59 |
|
121 |
|
| - |
|
122 |
// 2. Random draw from the order-value slab
|
| - |
|
123 |
List<GiftEntity> candidates = new ArrayList<>(findSlabGifts(pool, amount));
|
| - |
|
124 |
Collections.shuffle(candidates);
|
| - |
|
125 |
for (GiftEntity gift : candidates) {
|
| 60 |
ScratchOffer availedOffer = scratchOfferRepository.getScractchOfferByCustomerIdAndOfferId(fofoOrder.getCustomerId(), eligibleOffer.getId());
|
126 |
if (awardWithStock(fofoOrder, offerById.get(gift.getOfferId()), gift)) {
|
| - |
|
127 |
return;
|
| - |
|
128 |
}
|
| - |
|
129 |
}
|
| - |
|
130 |
|
| - |
|
131 |
// 3. Floor: the offer's default gift, always issued, never Better Luck Next Time
|
| - |
|
132 |
GiftEntity defaultGift = resolveDefaultGift(pool, eligibleOffers);
|
| 61 |
if (availedOffer != null) {
|
133 |
if (defaultGift == null) {
|
| 62 |
logger.info("Customer {} already availed offer {}", fofoOrder.getCustomerId(), eligibleOffer.getId());
|
134 |
logger.error("No default gift on any active offer, order {} gets no scratch card", fofoOrder.getId());
|
| 63 |
return;
|
135 |
return;
|
| 64 |
}
|
136 |
}
|
| - |
|
137 |
awardUnconditional(fofoOrder, offerById.get(defaultGift.getOfferId()), defaultGift);
|
| - |
|
138 |
}
|
| - |
|
139 |
|
| - |
|
140 |
/** One gift per customer per offer: drop any offer this customer already holds a card for. */
|
| - |
|
141 |
private List<OfferEntity> filterOffersNotAvailed(List<OfferEntity> offers, int customerId) {
|
| - |
|
142 |
List<OfferEntity> eligible = new ArrayList<>();
|
| - |
|
143 |
for (OfferEntity offer : offers) {
|
| - |
|
144 |
if (scratchOfferRepository.getScractchOfferByCustomerIdAndOfferId(customerId, offer.getId()) == null) {
|
| - |
|
145 |
eligible.add(offer);
|
| - |
|
146 |
} else {
|
| - |
|
147 |
logger.info("Customer {} already availed offer {}", customerId, offer.getId());
|
| - |
|
148 |
}
|
| - |
|
149 |
}
|
| - |
|
150 |
return eligible;
|
| - |
|
151 |
}
|
| 65 |
|
152 |
|
| - |
|
153 |
private GiftEntity findSpecialPartnerGift(List<GiftEntity> pool, int fofoId, double amount) {
|
| - |
|
154 |
if (amount <= SPECIAL_GIFT_MIN_ORDER_VALUE) {
|
| - |
|
155 |
return null;
|
| - |
|
156 |
}
|
| - |
|
157 |
GiftEntity match = null;
|
| 66 |
this.processOffer(eligibleOffer, fofoOrder);
|
158 |
for (GiftEntity gift : pool) {
|
| - |
|
159 |
if (!hasPartnerList(gift) || !hasStock(gift) || !csvContainsId(gift.getFofoStore(), fofoId)) {
|
| - |
|
160 |
continue;
|
| - |
|
161 |
}
|
| - |
|
162 |
// deterministic when a partner is listed on more than one special gift
|
| - |
|
163 |
if (match == null || gift.getId() < match.getId()) {
|
| - |
|
164 |
match = gift;
|
| - |
|
165 |
}
|
| - |
|
166 |
}
|
| - |
|
167 |
if (match != null) {
|
| - |
|
168 |
logger.info("Special partner gift {} matched partner {} on amount {}", match.getId(), fofoId, amount);
|
| - |
|
169 |
}
|
| - |
|
170 |
return match;
|
| 67 |
}
|
171 |
}
|
| 68 |
|
172 |
|
| - |
|
173 |
/** Random-pool gifts whose cart-value band contains the order amount. Excludes specials and the default. */
|
| - |
|
174 |
private List<GiftEntity> findSlabGifts(List<GiftEntity> pool, double amount) {
|
| - |
|
175 |
List<GiftEntity> slab = new ArrayList<>();
|
| - |
|
176 |
for (GiftEntity gift : pool) {
|
| - |
|
177 |
if (hasPartnerList(gift) || isDefaultGift(gift) || !hasStock(gift)) {
|
| - |
|
178 |
continue;
|
| - |
|
179 |
}
|
| - |
|
180 |
if (gift.getMinCartValue() == null && gift.getMaxCartValue() == null) {
|
| - |
|
181 |
logger.warn("Gift {} has no cart-value bounds, excluding it from the slab pool", gift.getId());
|
| - |
|
182 |
continue;
|
| - |
|
183 |
}
|
| - |
|
184 |
double min = gift.getMinCartValue() == null ? 0d : gift.getMinCartValue();
|
| - |
|
185 |
double max = gift.getMaxCartValue() == null ? Double.MAX_VALUE : gift.getMaxCartValue();
|
| - |
|
186 |
if (amount >= min && amount <= max) {
|
| - |
|
187 |
slab.add(gift);
|
| - |
|
188 |
}
|
| - |
|
189 |
}
|
| - |
|
190 |
return slab;
|
| - |
|
191 |
}
|
| - |
|
192 |
|
| - |
|
193 |
/**
|
| - |
|
194 |
* The default gift of the first eligible offer that has one. Resolved from the already-fetched pool
|
| - |
|
195 |
* rather than via getDefaultGift, whose getSingleResult throws when an offer has two defaults.
|
| - |
|
196 |
*/
|
| - |
|
197 |
private GiftEntity resolveDefaultGift(List<GiftEntity> pool, List<OfferEntity> offers) {
|
| - |
|
198 |
for (OfferEntity offer : offers) {
|
| - |
|
199 |
GiftEntity best = null;
|
| - |
|
200 |
for (GiftEntity gift : pool) {
|
| - |
|
201 |
if (gift.getOfferId() == null || gift.getOfferId() != offer.getId() || !isDefaultGift(gift)) {
|
| - |
|
202 |
continue;
|
| - |
|
203 |
}
|
| - |
|
204 |
if (best == null || gift.getId() < best.getId()) {
|
| - |
|
205 |
best = gift;
|
| - |
|
206 |
}
|
| - |
|
207 |
}
|
| - |
|
208 |
if (best != null) {
|
| - |
|
209 |
return best;
|
| - |
|
210 |
}
|
| - |
|
211 |
}
|
| - |
|
212 |
return null;
|
| - |
|
213 |
}
|
| - |
|
214 |
|
| - |
|
215 |
/** Award only if the atomic decrement wins; false lets the caller try the next candidate. */
|
| - |
|
216 |
private boolean awardWithStock(FofoOrder order, OfferEntity offer, GiftEntity gift) {
|
| - |
|
217 |
if (offer == null || gift == null) {
|
| - |
|
218 |
return false;
|
| - |
|
219 |
}
|
| - |
|
220 |
if (giftRepository.decrementMaxRedemptions(gift.getId()) == 0) {
|
| - |
|
221 |
logger.warn("Gift {} exhausted by a concurrent order, trying the next candidate", gift.getId());
|
| - |
|
222 |
return false;
|
| - |
|
223 |
}
|
| - |
|
224 |
persistScratchV2(order, offer, gift);
|
| - |
|
225 |
return true;
|
| - |
|
226 |
}
|
| - |
|
227 |
|
| - |
|
228 |
/** Floor award: attempt the decrement for accounting, but issue the card either way. */
|
| - |
|
229 |
private void awardUnconditional(FofoOrder order, OfferEntity offer, GiftEntity gift) {
|
| - |
|
230 |
if (offer == null || gift == null) {
|
| - |
|
231 |
logger.error("Cannot resolve the floor gift for order {}", order.getId());
|
| - |
|
232 |
return;
|
| - |
|
233 |
}
|
| - |
|
234 |
if (giftRepository.decrementMaxRedemptions(gift.getId()) == 0) {
|
| - |
|
235 |
logger.warn("Default gift {} is out of stock but is still being issued for order {}", gift.getId(), order.getId());
|
| - |
|
236 |
}
|
| - |
|
237 |
persistScratchV2(order, offer, gift);
|
| - |
|
238 |
}
|
| - |
|
239 |
|
| - |
|
240 |
private void persistScratchV2(FofoOrder order, OfferEntity offer, GiftEntity gift) {
|
| - |
|
241 |
if (order.getInvoiceNumber() == null) {
|
| - |
|
242 |
logger.warn("Order {} has no invoice number, persisting the scratch card anyway", order.getId());
|
| - |
|
243 |
}
|
| - |
|
244 |
ScratchOffer so = new ScratchOffer();
|
| - |
|
245 |
so.setCustomerId(order.getCustomerId());
|
| - |
|
246 |
so.setOfferId(offer.getId());
|
| - |
|
247 |
so.setGiftId(gift.getId());
|
| - |
|
248 |
so.setInvoiceNumber(order.getInvoiceNumber());
|
| - |
|
249 |
so.setCreatedTimestamp(LocalDateTime.now());
|
| - |
|
250 |
so.setOfferName(gift.getName());
|
| - |
|
251 |
scratchOfferRepository.persist(so);
|
| - |
|
252 |
logger.info("Scratch card created for order {} customer {} offer {} gift {} ({})",
|
| - |
|
253 |
order.getId(), order.getCustomerId(), offer.getId(), gift.getId(), gift.getName());
|
| - |
|
254 |
}
|
| - |
|
255 |
|
| - |
|
256 |
/** A gift with a partner list is partner-restricted; one without belongs to the random pool. */
|
| - |
|
257 |
private static boolean hasPartnerList(GiftEntity gift) {
|
| - |
|
258 |
return gift.getFofoStore() != null && !gift.getFofoStore().trim().isEmpty();
|
| - |
|
259 |
}
|
| - |
|
260 |
|
| - |
|
261 |
private static boolean hasStock(GiftEntity gift) {
|
| - |
|
262 |
return gift.getMaxRedemptions() != null && gift.getMaxRedemptions() > 0;
|
| - |
|
263 |
}
|
| - |
|
264 |
|
| - |
|
265 |
private static boolean isDefaultGift(GiftEntity gift) {
|
| - |
|
266 |
return Boolean.TRUE.equals(gift.isDefault());
|
| - |
|
267 |
}
|
| - |
|
268 |
|
| - |
|
269 |
private static boolean csvContainsId(String csv, int id) {
|
| - |
|
270 |
if (csv == null || csv.trim().isEmpty()) {
|
| - |
|
271 |
return false;
|
| - |
|
272 |
}
|
| - |
|
273 |
for (String token : csv.split(",")) {
|
| - |
|
274 |
String trimmed = token.trim();
|
| - |
|
275 |
if (trimmed.isEmpty()) {
|
| - |
|
276 |
continue;
|
| - |
|
277 |
}
|
| - |
|
278 |
try {
|
| - |
|
279 |
if (Integer.parseInt(trimmed) == id) {
|
| - |
|
280 |
return true;
|
| - |
|
281 |
}
|
| - |
|
282 |
} catch (NumberFormatException e) {
|
| - |
|
283 |
// malformed entry, skip it
|
| - |
|
284 |
}
|
| - |
|
285 |
}
|
| - |
|
286 |
return false;
|
| - |
|
287 |
}
|
| - |
|
288 |
|
| - |
|
289 |
// ------------------------------------------------------------------------------ end of V2 block
|
| - |
|
290 |
|
| 69 |
private OfferEntity findEligibleOffer(List<OfferEntity> activeOffers, FofoOrder fofoOrder) {
|
291 |
private OfferEntity findEligibleOffer(List<OfferEntity> activeOffers, FofoOrder fofoOrder) {
|
| 70 |
int customerFofoId = fofoOrder.getFofoId();
|
292 |
int customerFofoId = fofoOrder.getFofoId();
|
| 71 |
|
293 |
|
| 72 |
// Check PartnerWise offers first
|
294 |
// Check PartnerWise offers first
|
| 73 |
for (OfferEntity activeOffer : activeOffers) {
|
295 |
for (OfferEntity activeOffer : activeOffers) {
|