| 37935 |
amit |
1 |
package com.spice.profitmandi.service.user;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
|
|
|
4 |
import com.spice.profitmandi.common.model.ProfitMandiConstants;
|
|
|
5 |
import com.spice.profitmandi.dao.entity.auth.AuthUser;
|
|
|
6 |
import com.spice.profitmandi.dao.entity.cs.Position;
|
|
|
7 |
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
|
|
|
8 |
import com.spice.profitmandi.dao.entity.fofo.StoreRevival;
|
|
|
9 |
import com.spice.profitmandi.dao.entity.fofo.StoreStatusLog;
|
|
|
10 |
import com.spice.profitmandi.dao.entity.transaction.Loan;
|
|
|
11 |
import com.spice.profitmandi.dao.entity.transaction.UserWallet;
|
|
|
12 |
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
|
|
|
13 |
import com.spice.profitmandi.dao.enumuration.fofo.StoreRevivalType;
|
|
|
14 |
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatus;
|
|
|
15 |
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusAction;
|
|
|
16 |
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusSource;
|
|
|
17 |
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
|
|
|
18 |
import com.spice.profitmandi.dao.repository.cs.CsService;
|
|
|
19 |
import com.spice.profitmandi.dao.repository.cs.PositionRepository;
|
|
|
20 |
import com.spice.profitmandi.dao.repository.dtr.FofoStoreRepository;
|
|
|
21 |
import com.spice.profitmandi.dao.repository.fofo.StoreRevivalRepository;
|
|
|
22 |
import com.spice.profitmandi.dao.repository.fofo.StoreStatusLogRepository;
|
|
|
23 |
import com.spice.profitmandi.dao.repository.transaction.LoanRepository;
|
|
|
24 |
import com.spice.profitmandi.dao.repository.transaction.UserWalletHistoryRepository;
|
|
|
25 |
import com.spice.profitmandi.dao.repository.transaction.UserWalletRepository;
|
|
|
26 |
import com.spice.profitmandi.service.mail.MailOutboxService;
|
|
|
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.Service;
|
|
|
31 |
|
|
|
32 |
import java.math.BigDecimal;
|
|
|
33 |
import java.time.LocalDate;
|
|
|
34 |
import java.time.LocalDateTime;
|
|
|
35 |
import java.time.format.DateTimeFormatter;
|
|
|
36 |
import java.time.temporal.ChronoUnit;
|
|
|
37 |
import java.util.*;
|
|
|
38 |
import java.util.stream.Collectors;
|
|
|
39 |
|
|
|
40 |
/**
|
|
|
41 |
* The only writer of a partner store's status (fofo_store.store_status, plus the legacy active / closed columns).
|
|
|
42 |
* Every change checks the allowed transitions, who may make it and the mandatory reason, then writes a
|
|
|
43 |
* fofo.store_status_log row. Pause, un-pause and suspend mail Tarun, Kamini and the partner's Sales L5 and above.
|
|
|
44 |
*/
|
|
|
45 |
@Service
|
|
|
46 |
public class StoreStatusService {
|
|
|
47 |
|
|
|
48 |
private static final Logger LOGGER = LogManager.getLogger(StoreStatusService.class);
|
|
|
49 |
|
|
|
50 |
static final String TARUN = "tarun.verma@smartdukaan.com";
|
|
|
51 |
static final String KAMINI = "kamini.sharma@smartdukaan.com";
|
|
|
52 |
static final String NEERAJ = "neeraj.gupta@smartdukaan.com";
|
|
|
53 |
private static final List<String> NAMED = Collections.unmodifiableList(Arrays.asList(TARUN, KAMINI));
|
|
|
54 |
|
|
|
55 |
public static final String ALLOWED_NAMED = "NAMED";
|
|
|
56 |
public static final String ALLOWED_SALES_L5 = "SALES_L5+";
|
|
|
57 |
public static final String ALLOWED_ACCOUNTS_L3 = "ACCOUNTS_L3+";
|
|
|
58 |
public static final String ALLOWED_ACCOUNTS_L4 = "ACCOUNTS_L4+";
|
|
|
59 |
public static final String ALLOWED_SYSTEM = "SYSTEM";
|
|
|
60 |
public static final String ALLOWED_LOI = "LOI_APPROVAL";
|
|
|
61 |
|
|
|
62 |
private static final int MAX_REASON = 1000;
|
|
|
63 |
private static final int CLOSURE_REMINDER_DAYS = 7;
|
|
|
64 |
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm a");
|
|
|
65 |
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy");
|
|
|
66 |
|
|
|
67 |
@Autowired
|
|
|
68 |
private FofoStoreRepository fofoStoreRepository;
|
|
|
69 |
|
|
|
70 |
@Autowired
|
|
|
71 |
private StoreStatusLogRepository storeStatusLogRepository;
|
|
|
72 |
|
|
|
73 |
@Autowired
|
|
|
74 |
private StoreRevivalRepository storeRevivalRepository;
|
|
|
75 |
|
|
|
76 |
@Autowired
|
|
|
77 |
private AuthRepository authRepository;
|
|
|
78 |
|
|
|
79 |
@Autowired
|
|
|
80 |
private PositionRepository positionRepository;
|
|
|
81 |
|
|
|
82 |
@Autowired
|
|
|
83 |
private CsService csService;
|
|
|
84 |
|
|
|
85 |
@Autowired
|
|
|
86 |
private UserWalletRepository userWalletRepository;
|
|
|
87 |
|
|
|
88 |
@Autowired
|
|
|
89 |
private UserWalletHistoryRepository userWalletHistoryRepository;
|
|
|
90 |
|
|
|
91 |
@Autowired
|
|
|
92 |
private LoanRepository loanRepository;
|
|
|
93 |
|
|
|
94 |
@Autowired
|
|
|
95 |
private MailOutboxService mailOutboxService;
|
|
|
96 |
|
|
|
97 |
// ── Status changes ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
98 |
|
|
|
99 |
/** ACTIVE → PAUSED (Tarun, Kamini, Sales L5+ for the partner) or SUSPENDED → PAUSED (activation rights). */
|
|
|
100 |
public void pause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
|
|
|
101 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
102 |
String allowedAs = fofoStore.getStoreStatus() == StoreStatus.ACTIVE
|
|
|
103 |
? allowedToPause(fofoId, changedBy) : allowedToChange(changedBy);
|
|
|
104 |
changeStatus(fofoStore, StoreStatus.PAUSED, StoreStatusAction.PAUSE, reason, changedBy, allowedAs, source, null, null);
|
|
|
105 |
sendStatusChangeMail(fofoStore, StoreStatusAction.PAUSE, reason, changedBy);
|
|
|
106 |
}
|
|
|
107 |
|
|
|
108 |
/** PAUSED → ACTIVE. */
|
|
|
109 |
public void unpause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
|
|
|
110 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
111 |
requireStatus(fofoStore, StoreStatus.PAUSED, "un-pause");
|
|
|
112 |
String allowedAs = allowedToChange(changedBy);
|
|
|
113 |
changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.UNPAUSE, reason, changedBy, allowedAs, source, null, null);
|
|
|
114 |
sendStatusChangeMail(fofoStore, StoreStatusAction.UNPAUSE, reason, changedBy);
|
|
|
115 |
}
|
|
|
116 |
|
|
|
117 |
/** ACTIVE or PAUSED → SUSPENDED (nearing closure, logged out). */
|
|
|
118 |
public void suspend(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
|
|
|
119 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
120 |
String allowedAs = allowedToChange(changedBy);
|
|
|
121 |
changeStatus(fofoStore, StoreStatus.SUSPENDED, StoreStatusAction.SUSPEND, reason, changedBy, allowedAs, source, null, null);
|
|
|
122 |
sendStatusChangeMail(fofoStore, StoreStatusAction.SUSPEND, reason, changedBy);
|
|
|
123 |
}
|
|
|
124 |
|
|
|
125 |
/** SUSPENDED → ACTIVE for good (a PAUSED store is un-paused instead). Ends any temporary activation. */
|
|
|
126 |
public void activate(int fofoId, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
|
|
|
127 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
128 |
String allowedAs = allowedToChange(changedBy);
|
|
|
129 |
if (fofoStore.getStoreStatus() == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {
|
|
|
130 |
// Temporarily active: make it permanent.
|
|
|
131 |
StoreStatus previous = fofoStore.getStoreStatus();
|
|
|
132 |
fofoStore.setActiveTimeStamp(null);
|
|
|
133 |
writeLog(fofoStore, previous, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);
|
|
|
134 |
return;
|
|
|
135 |
}
|
|
|
136 |
requireStatus(fofoStore, StoreStatus.SUSPENDED, "activate");
|
|
|
137 |
changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);
|
|
|
138 |
}
|
|
|
139 |
|
|
|
140 |
/**
|
|
|
141 |
* PAUSED or SUSPENDED → ACTIVE until the start of today + {@code days}. The early-morning job then returns the
|
|
|
142 |
* store to the status it had before.
|
|
|
143 |
*/
|
|
|
144 |
public void activateTemporarily(int fofoId, int days, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
|
|
|
145 |
if (days <= 0) {
|
|
|
146 |
throw new ProfitMandiBusinessException("Temporary Activation", "Invalid Days", "Please enter a number of days above zero");
|
|
|
147 |
}
|
|
|
148 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
149 |
StoreStatus current = fofoStore.getStoreStatus();
|
|
|
150 |
if (current != StoreStatus.PAUSED && current != StoreStatus.SUSPENDED) {
|
|
|
151 |
throw new ProfitMandiBusinessException("Temporary Activation", "Not Allowed",
|
|
|
152 |
"Only a paused or suspended store can be activated temporarily; this store is " + current);
|
|
|
153 |
}
|
|
|
154 |
String allowedAs = allowedToChange(changedBy);
|
|
|
155 |
LocalDateTime activeUntil = LocalDate.now().atStartOfDay().plusDays(days);
|
|
|
156 |
changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.TEMP_ACTIVATE, null, changedBy, allowedAs, source, activeUntil, null);
|
|
|
157 |
fofoStore.setActiveTimeStamp(activeUntil);
|
|
|
158 |
}
|
|
|
159 |
|
|
|
160 |
/** Cron: every temporarily active store whose period has ended goes back to the status it had before. */
|
|
|
161 |
public int expireTemporaryActivations() throws ProfitMandiBusinessException {
|
|
|
162 |
LocalDateTime now = LocalDateTime.now();
|
|
|
163 |
int expired = 0;
|
|
|
164 |
for (FofoStore fofoStore : fofoStoreRepository.selectByStoreStatus(StoreStatus.ACTIVE)) {
|
|
|
165 |
if (fofoStore.getActiveTimeStamp() == null || fofoStore.getActiveTimeStamp().isAfter(now)) {
|
|
|
166 |
continue;
|
|
|
167 |
}
|
|
|
168 |
StoreStatusLog tempLog = storeStatusLogRepository.selectLatest(fofoStore.getId(), StoreStatusAction.TEMP_ACTIVATE);
|
|
|
169 |
StoreStatus previous = tempLog != null && tempLog.getFromStatus() != null ? tempLog.getFromStatus() : StoreStatus.SUSPENDED;
|
|
|
170 |
changeStatus(fofoStore, previous, StoreStatusAction.TEMP_EXPIRED, null, "checkPartnerActiveStore",
|
|
|
171 |
ALLOWED_SYSTEM, StoreStatusSource.CRON, null, null);
|
|
|
172 |
expired++;
|
|
|
173 |
}
|
|
|
174 |
LOGGER.info("Temporary activations expired: {}", expired);
|
|
|
175 |
return expired;
|
|
|
176 |
}
|
|
|
177 |
|
|
|
178 |
// ── Closure ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
179 |
|
|
|
180 |
/**
|
|
|
181 |
* Conditions not met for closing this store, empty when it can be closed: SUSPENDED, wallet at ₹0 and no unsettled
|
|
|
182 |
* loan (limit blocks included). Who is closing is checked separately by {@link #allowedToClose(String)}.
|
|
|
183 |
*/
|
|
|
184 |
public List<String> closeBlockers(FofoStore fofoStore) throws ProfitMandiBusinessException {
|
|
|
185 |
List<String> blockers = new ArrayList<>();
|
|
|
186 |
if (fofoStore.getStoreStatus() != StoreStatus.SUSPENDED) {
|
|
|
187 |
blockers.add("Store must be suspended first (it is " + fofoStore.getStoreStatus() + ")");
|
|
|
188 |
}
|
|
|
189 |
long wallet = walletBalance(fofoStore.getId());
|
|
|
190 |
if (wallet != 0) {
|
|
|
191 |
blockers.add("Wallet balance must be ₹0 (it is ₹" + wallet + ")");
|
|
|
192 |
}
|
|
|
193 |
List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
|
|
|
194 |
if (!loans.isEmpty()) {
|
|
|
195 |
blockers.add("All loans must be settled (" + loans.size() + " open, ₹" + pendingTotal(loans) + " pending)");
|
|
|
196 |
}
|
|
|
197 |
return blockers;
|
|
|
198 |
}
|
|
|
199 |
|
|
|
200 |
/** Throws unless the store can be closed by {@code closedBy}; returns the role that allows it. */
|
|
|
201 |
public String assertCanClose(FofoStore fofoStore, String closedBy) throws ProfitMandiBusinessException {
|
|
|
202 |
String allowedAs = allowedToClose(closedBy);
|
|
|
203 |
if (fofoStore.isClosed()) {
|
|
|
204 |
throw new ProfitMandiBusinessException("Store Closed", "Store Already Closed", "This store is already permanently closed");
|
|
|
205 |
}
|
|
|
206 |
List<String> blockers = closeBlockers(fofoStore);
|
|
|
207 |
if (!blockers.isEmpty()) {
|
|
|
208 |
throw new ProfitMandiBusinessException("Store Closure", "Closure Not Allowed", String.join("; ", blockers));
|
|
|
209 |
}
|
|
|
210 |
return allowedAs;
|
|
|
211 |
}
|
|
|
212 |
|
|
|
213 |
/** SUSPENDED → CLOSED. Called by StoreClosureService once the closure record exists. */
|
|
|
214 |
void markClosed(FofoStore fofoStore, String remark, String closedBy, String allowedAs, StoreStatusSource source,
|
|
|
215 |
int storeClosureId) throws ProfitMandiBusinessException {
|
|
|
216 |
changeStatus(fofoStore, StoreStatus.CLOSED, StoreStatusAction.CLOSE, remark, closedBy, allowedAs, source, null, storeClosureId);
|
|
|
217 |
fofoStore.setClosedTimestamp(LocalDateTime.now());
|
|
|
218 |
}
|
|
|
219 |
|
|
|
220 |
// ── Revival ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
221 |
|
|
|
222 |
/** Only SUSPENDED (same store) and CLOSED (new store) partners can be revived. */
|
|
|
223 |
public void assertRevivable(FofoStore fofoStore) throws ProfitMandiBusinessException {
|
|
|
224 |
StoreStatus status = fofoStore.getStoreStatus();
|
|
|
225 |
if (status != StoreStatus.SUSPENDED && status != StoreStatus.CLOSED) {
|
|
|
226 |
throw new ProfitMandiBusinessException("Revival", "Not Allowed", "Store " + fofoStore.getCode() + " is " + status
|
|
|
227 |
+ ". Only suspended or closed stores can be revived; reactivate or un-pause it instead.");
|
|
|
228 |
}
|
|
|
229 |
}
|
|
|
230 |
|
|
|
231 |
/** SUSPENDED → ACTIVE with the same code, once the revival payment is approved. */
|
|
|
232 |
public void reviveSameStore(int fofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {
|
|
|
233 |
FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
234 |
requireStatus(fofoStore, StoreStatus.SUSPENDED, "revive in place");
|
|
|
235 |
changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.REVIVAL, null, revivedBy, ALLOWED_LOI,
|
|
|
236 |
StoreStatusSource.LOI_REVIVAL, null, null);
|
|
|
237 |
persistRevival(fofoId, fofoId, StoreRevivalType.SAME_STORE, loiId, revivedBy);
|
|
|
238 |
}
|
|
|
239 |
|
|
|
240 |
/** A CLOSED partner was given a new store (new code); the old one stays CLOSED. Bookkeeping only. */
|
|
|
241 |
public void recordNewStoreRevival(int oldFofoId, int newFofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {
|
|
|
242 |
FofoStore oldStore = fofoStoreRepository.selectByRetailerId(oldFofoId);
|
|
|
243 |
requireStatus(oldStore, StoreStatus.CLOSED, "revive as a new store");
|
|
|
244 |
FofoStore newStore = fofoStoreRepository.selectByRetailerId(newFofoId);
|
|
|
245 |
writeLog(newStore, null, StoreStatusAction.REVIVAL, "New store for closed " + oldStore.getCode(), revivedBy,
|
|
|
246 |
ALLOWED_LOI, StoreStatusSource.LOI_REVIVAL, null, null);
|
|
|
247 |
persistRevival(oldFofoId, newFofoId, StoreRevivalType.NEW_STORE, loiId, revivedBy);
|
|
|
248 |
}
|
|
|
249 |
|
|
|
250 |
// ── Wallet ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
251 |
|
|
|
252 |
/** Call at partner-initiated spends only; system, admin and cron debits must not call this. */
|
|
|
253 |
public void assertCanSpendWallet(int fofoId) throws ProfitMandiBusinessException {
|
|
|
254 |
FofoStore fofoStore;
|
|
|
255 |
try {
|
|
|
256 |
fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
|
|
|
257 |
} catch (ProfitMandiBusinessException e) {
|
|
|
258 |
return; // a retailer without a partner store
|
|
|
259 |
}
|
|
|
260 |
StoreStatus status = fofoStore.getStoreStatus();
|
|
|
261 |
if (!status.canSpendWallet()) {
|
|
|
262 |
throw new ProfitMandiBusinessException("Wallet", "Wallet Blocked", status == StoreStatus.PAUSED
|
|
|
263 |
? "Your store is paused, so wallet payments are not allowed. Please contact your sales manager."
|
|
|
264 |
: "Your store is " + status.name().toLowerCase() + ", so wallet payments are not allowed.");
|
|
|
265 |
}
|
|
|
266 |
}
|
|
|
267 |
|
|
|
268 |
// ── History and permissions for the UI ────────────────────────────────────────────────────────────────────
|
|
|
269 |
|
|
|
270 |
public List<StoreStatusLog> history(int fofoId) {
|
|
|
271 |
return storeStatusLogRepository.selectByFofoId(fofoId);
|
|
|
272 |
}
|
|
|
273 |
|
|
|
274 |
/** Actions {@code emailId} may take on this store right now (FSM and permission; closure conditions excluded). */
|
|
|
275 |
public Set<StoreStatusAction> allowedActions(FofoStore fofoStore, String emailId) {
|
|
|
276 |
Set<StoreStatusAction> actions = EnumSet.noneOf(StoreStatusAction.class);
|
|
|
277 |
StoreStatus status = fofoStore.getStoreStatus();
|
|
|
278 |
boolean change = isAllowed(() -> allowedToChange(emailId));
|
|
|
279 |
if (status == StoreStatus.ACTIVE && isAllowed(() -> allowedToPause(fofoStore.getId(), emailId))
|
|
|
280 |
|| status == StoreStatus.SUSPENDED && change) {
|
|
|
281 |
actions.add(StoreStatusAction.PAUSE);
|
|
|
282 |
}
|
|
|
283 |
if (change) {
|
|
|
284 |
if (status == StoreStatus.PAUSED) {
|
|
|
285 |
actions.add(StoreStatusAction.UNPAUSE);
|
|
|
286 |
}
|
|
|
287 |
if (status == StoreStatus.ACTIVE || status == StoreStatus.PAUSED) {
|
|
|
288 |
actions.add(StoreStatusAction.SUSPEND);
|
|
|
289 |
}
|
|
|
290 |
if (status == StoreStatus.SUSPENDED || status == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {
|
|
|
291 |
actions.add(StoreStatusAction.ACTIVATE);
|
|
|
292 |
}
|
|
|
293 |
if (status == StoreStatus.PAUSED || status == StoreStatus.SUSPENDED) {
|
|
|
294 |
actions.add(StoreStatusAction.TEMP_ACTIVATE);
|
|
|
295 |
}
|
|
|
296 |
}
|
|
|
297 |
if (status == StoreStatus.SUSPENDED && isAllowed(() -> allowedToClose(emailId))) {
|
|
|
298 |
actions.add(StoreStatusAction.CLOSE);
|
|
|
299 |
}
|
|
|
300 |
return actions;
|
|
|
301 |
}
|
|
|
302 |
|
|
|
303 |
/**
|
|
|
304 |
* Which buttons a store list may show to {@code emailId}: keys pause, change and close. Pause is only a hint here
|
|
|
305 |
* (Sales L5+ anywhere); each action re-checks the partner coverage itself.
|
|
|
306 |
*/
|
|
|
307 |
public Map<String, Boolean> listPermissions(String emailId) throws ProfitMandiBusinessException {
|
|
|
308 |
String email = normalise(emailId);
|
|
|
309 |
Map<String, Boolean> permissions = new HashMap<>();
|
|
|
310 |
permissions.put("pause", NAMED.contains(email)
|
|
|
311 |
|| holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_SALES, EscalationType.L5));
|
|
|
312 |
permissions.put("change", isAllowed(() -> allowedToChange(email)));
|
|
|
313 |
permissions.put("close", isAllowed(() -> allowedToClose(email)));
|
|
|
314 |
return permissions;
|
|
|
315 |
}
|
|
|
316 |
|
|
|
317 |
/** Pause: Tarun, Kamini, or Sales L5 and above who cover this partner. */
|
|
|
318 |
public String allowedToPause(int fofoId, String emailId) throws ProfitMandiBusinessException {
|
|
|
319 |
String email = normalise(emailId);
|
|
|
320 |
if (NAMED.contains(email)) {
|
|
|
321 |
return ALLOWED_NAMED;
|
|
|
322 |
}
|
|
|
323 |
boolean coversPartner = csService.getAuthUsersByPartnerIdAndCategory(fofoId, ProfitMandiConstants.TICKET_CATEGORY_SALES,
|
|
|
324 |
levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull)
|
|
|
325 |
.anyMatch(x -> x.equalsIgnoreCase(email));
|
|
|
326 |
if (coversPartner) {
|
|
|
327 |
return ALLOWED_SALES_L5;
|
|
|
328 |
}
|
|
|
329 |
throw denied("pause this store");
|
|
|
330 |
}
|
|
|
331 |
|
|
|
332 |
/** Suspend, activate, un-pause, temporary activate: Tarun, Kamini, Neeraj, or Accounts L4 and above. */
|
|
|
333 |
public String allowedToChange(String emailId) throws ProfitMandiBusinessException {
|
|
|
334 |
String email = normalise(emailId);
|
|
|
335 |
if (NAMED.contains(email) || NEERAJ.equals(email)) {
|
|
|
336 |
return ALLOWED_NAMED;
|
|
|
337 |
}
|
|
|
338 |
if (holdsAccountsLevel(email, EscalationType.L4)) {
|
|
|
339 |
return ALLOWED_ACCOUNTS_L4;
|
|
|
340 |
}
|
|
|
341 |
throw denied("change this store's status");
|
|
|
342 |
}
|
|
|
343 |
|
|
|
344 |
/** Close: Tarun, Kamini, or Accounts L3 and above. */
|
|
|
345 |
public String allowedToClose(String emailId) throws ProfitMandiBusinessException {
|
|
|
346 |
String email = normalise(emailId);
|
|
|
347 |
if (NAMED.contains(email)) {
|
|
|
348 |
return ALLOWED_NAMED;
|
|
|
349 |
}
|
|
|
350 |
if (holdsAccountsLevel(email, EscalationType.L3)) {
|
|
|
351 |
return ALLOWED_ACCOUNTS_L3;
|
|
|
352 |
}
|
|
|
353 |
throw denied("close store");
|
|
|
354 |
}
|
|
|
355 |
|
|
|
356 |
// ── Weekly closure reminder ───────────────────────────────────────────────────────────────────────────────
|
|
|
357 |
|
|
|
358 |
/**
|
|
|
359 |
* Monday digest of stores SUSPENDED for more than 7 days, To Neeraj with the other closers in CC. Stores whose
|
|
|
360 |
* only entry is the migration baseline were already inactive before the log existed, so they are always listed.
|
|
|
361 |
*/
|
|
|
362 |
public int sendClosureReminder() throws ProfitMandiBusinessException {
|
|
|
363 |
List<FofoStore> suspended = fofoStoreRepository.selectByStoreStatus(StoreStatus.SUSPENDED);
|
|
|
364 |
Map<Integer, StoreStatusLog> entries = storeStatusLogRepository.selectLatestEntries(
|
|
|
365 |
suspended.stream().map(FofoStore::getId).collect(Collectors.toList()), StoreStatus.SUSPENDED);
|
|
|
366 |
LocalDateTime cutoff = LocalDateTime.now().minusDays(CLOSURE_REMINDER_DAYS);
|
|
|
367 |
|
|
|
368 |
StringBuilder rows = new StringBuilder();
|
|
|
369 |
int listed = 0;
|
|
|
370 |
int closable = 0;
|
|
|
371 |
for (FofoStore fofoStore : suspended) {
|
|
|
372 |
StoreStatusLog entry = entries.get(fofoStore.getId());
|
|
|
373 |
boolean baseline = entry == null || entry.getAction() == StoreStatusAction.BASELINE;
|
|
|
374 |
if (!baseline && entry.getCreateTimestamp().isAfter(cutoff)) {
|
|
|
375 |
continue;
|
|
|
376 |
}
|
|
|
377 |
long wallet = walletBalance(fofoStore.getId());
|
|
|
378 |
List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
|
|
|
379 |
boolean canClose = wallet == 0 && loans.isEmpty();
|
|
|
380 |
String since = baseline
|
|
|
381 |
? "before " + (entry == null ? "-" : entry.getCreateTimestamp().format(DATE_FORMAT))
|
|
|
382 |
: entry.getCreateTimestamp().format(DATE_FORMAT) + " ("
|
|
|
383 |
+ ChronoUnit.DAYS.between(entry.getCreateTimestamp().toLocalDate(), LocalDate.now()) + " days)";
|
|
|
384 |
rows.append("<tr><td>").append(fofoStore.getCode()).append("</td><td>")
|
|
|
385 |
.append(escape(Optional.ofNullable(fofoStore.getOutletName()).orElse("-"))).append("</td><td>")
|
|
|
386 |
.append(since).append("</td><td align=\"right\">").append(wallet).append("</td><td align=\"right\">")
|
|
|
387 |
.append(loans.isEmpty() ? "-" : loans.size() + " / ₹" + pendingTotal(loans)).append("</td><td>")
|
|
|
388 |
.append(canClose ? "<b>Yes</b>" : "No").append("</td></tr>");
|
|
|
389 |
listed++;
|
|
|
390 |
if (canClose) {
|
|
|
391 |
closable++;
|
|
|
392 |
}
|
|
|
393 |
}
|
|
|
394 |
if (listed == 0) {
|
|
|
395 |
LOGGER.info("Closure reminder: no store suspended for more than {} days", CLOSURE_REMINDER_DAYS);
|
|
|
396 |
return 0;
|
|
|
397 |
}
|
|
|
398 |
Set<String> cc = new LinkedHashSet<>(NAMED);
|
|
|
399 |
cc.addAll(accountsEmailsFrom(EscalationType.L3));
|
|
|
400 |
cc.remove(NEERAJ);
|
|
|
401 |
String subject = "Suspended stores pending closure - " + listed + " stores, " + closable + " closable now";
|
|
|
402 |
String body = "<p>Dear Neeraj,</p><p>These stores have been suspended for more than " + CLOSURE_REMINDER_DAYS
|
|
|
403 |
+ " days. A store can be closed once its wallet is at ₹0 and every loan, including limit blocks, is settled.</p>"
|
|
|
404 |
+ "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\" style=\"border-collapse:collapse\">"
|
|
|
405 |
+ "<tr><th>Store</th><th>Name</th><th>Suspended since</th><th>Wallet ₹</th><th>Open loans</th><th>Closable now</th></tr>"
|
|
|
406 |
+ rows + "</table><p>Regards,<br>SmartDukaan</p>";
|
|
|
407 |
mailOutboxService.queueMail(new String[]{NEERAJ}, cc.toArray(new String[0]), null, subject, body, true,
|
|
|
408 |
"StoreStatusService.sendClosureReminder");
|
|
|
409 |
LOGGER.info("Closure reminder queued: {} stores, {} closable", listed, closable);
|
|
|
410 |
return listed;
|
|
|
411 |
}
|
|
|
412 |
|
|
|
413 |
// ── Internals ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
414 |
|
|
|
415 |
private void changeStatus(FofoStore fofoStore, StoreStatus target, StoreStatusAction action, String reason,
|
|
|
416 |
String changedBy, String allowedAs, StoreStatusSource source, LocalDateTime activeUntil,
|
|
|
417 |
Integer storeClosureId) throws ProfitMandiBusinessException {
|
|
|
418 |
StoreStatus from = fofoStore.getStoreStatus();
|
|
|
419 |
if (!from.canMoveTo(target)) {
|
|
|
420 |
throw new ProfitMandiBusinessException("Store Status", "Not Allowed",
|
|
|
421 |
"Store " + fofoStore.getCode() + " cannot move from " + from + " to " + target);
|
|
|
422 |
}
|
|
|
423 |
String cleanReason = reason == null ? null : reason.trim();
|
|
|
424 |
if (action.needsReason() && (cleanReason == null || cleanReason.isEmpty())) {
|
|
|
425 |
throw new ProfitMandiBusinessException("Store Status", "Reason Missing",
|
|
|
426 |
"Please give a reason to " + action.getLabel().toLowerCase() + " this store");
|
|
|
427 |
}
|
|
|
428 |
if (cleanReason != null && cleanReason.length() > MAX_REASON) {
|
|
|
429 |
throw new ProfitMandiBusinessException("Store Status", "Reason Too Long",
|
|
|
430 |
"Please keep the reason within " + MAX_REASON + " characters");
|
|
|
431 |
}
|
|
|
432 |
fofoStore.setStoreStatus(target);
|
|
|
433 |
if (action != StoreStatusAction.TEMP_ACTIVATE) {
|
|
|
434 |
fofoStore.setActiveTimeStamp(null);
|
|
|
435 |
}
|
|
|
436 |
writeLog(fofoStore, from, action, cleanReason, changedBy, allowedAs, source, activeUntil, storeClosureId);
|
|
|
437 |
LOGGER.info("Store {} {} -> {} ({}) by {} as {}", fofoStore.getCode(), from, target, action, changedBy, allowedAs);
|
|
|
438 |
}
|
|
|
439 |
|
|
|
440 |
private void writeLog(FofoStore fofoStore, StoreStatus from, StoreStatusAction action, String reason, String changedBy,
|
|
|
441 |
String allowedAs, StoreStatusSource source, LocalDateTime activeUntil, Integer storeClosureId) {
|
|
|
442 |
StoreStatusLog log = new StoreStatusLog();
|
|
|
443 |
log.setFofoId(fofoStore.getId());
|
|
|
444 |
log.setFromStatus(from);
|
|
|
445 |
log.setToStatus(fofoStore.getStoreStatus());
|
|
|
446 |
log.setAction(action);
|
|
|
447 |
log.setReason(reason);
|
|
|
448 |
log.setSource(source);
|
|
|
449 |
log.setChangedBy(changedBy);
|
|
|
450 |
log.setAllowedAs(allowedAs);
|
|
|
451 |
log.setActiveUntil(activeUntil);
|
|
|
452 |
log.setStoreClosureId(storeClosureId);
|
|
|
453 |
log.setCreateTimestamp(LocalDateTime.now());
|
|
|
454 |
storeStatusLogRepository.persist(log);
|
|
|
455 |
}
|
|
|
456 |
|
|
|
457 |
private void persistRevival(int oldFofoId, int newFofoId, StoreRevivalType type, Integer loiId, String revivedBy) {
|
|
|
458 |
StoreRevival revival = new StoreRevival();
|
|
|
459 |
revival.setOldFofoId(oldFofoId);
|
|
|
460 |
revival.setNewFofoId(newFofoId);
|
|
|
461 |
revival.setRevivalType(type);
|
|
|
462 |
revival.setLoiId(loiId);
|
|
|
463 |
revival.setRevivedBy(revivedBy);
|
|
|
464 |
revival.setCreateTimestamp(LocalDateTime.now());
|
|
|
465 |
storeRevivalRepository.persist(revival);
|
|
|
466 |
LOGGER.info("Store revival recorded: {}", revival);
|
|
|
467 |
}
|
|
|
468 |
|
|
|
469 |
private void sendStatusChangeMail(FofoStore fofoStore, StoreStatusAction action, String reason, String changedBy)
|
|
|
470 |
throws ProfitMandiBusinessException {
|
|
|
471 |
Set<String> to = new LinkedHashSet<>(NAMED);
|
|
|
472 |
csService.getAuthUsersByPartnerIdAndCategory(fofoStore.getId(), ProfitMandiConstants.TICKET_CATEGORY_SALES,
|
|
|
473 |
levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull)
|
|
|
474 |
.map(String::toLowerCase).forEach(to::add);
|
|
|
475 |
StoreStatusLog log = storeStatusLogRepository.selectLatest(fofoStore.getId(), action);
|
|
|
476 |
List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
|
|
|
477 |
String name = Optional.ofNullable(fofoStore.getOutletName()).orElse("-");
|
|
|
478 |
|
|
|
479 |
String subject = "Store " + action.getLabel() + " - " + fofoStore.getCode() + " - " + name;
|
|
|
480 |
StringBuilder body = new StringBuilder();
|
|
|
481 |
body.append("Dear Team,\n\nA partner store's status has changed.\n\n");
|
|
|
482 |
body.append("Partner : ").append(name).append(" (Partner Id ").append(fofoStore.getId()).append(")\n");
|
|
|
483 |
body.append("Store Code : ").append(fofoStore.getCode()).append("\n");
|
|
|
484 |
body.append("Status : ").append(log == null ? "-" : log.getFromStatus()).append(" -> ")
|
|
|
485 |
.append(fofoStore.getStoreStatus()).append("\n");
|
|
|
486 |
body.append("Reason : ").append(reason.trim()).append("\n");
|
|
|
487 |
body.append("Changed By : ").append(changedBy).append("\n");
|
|
|
488 |
body.append("Changed On : ").append(LocalDateTime.now().format(TIME_FORMAT)).append("\n");
|
|
|
489 |
body.append("Wallet : ₹").append(walletBalance(fofoStore.getId())).append("\n");
|
|
|
490 |
body.append("Open Loans : ").append(loans.isEmpty() ? "None" : loans.size() + " (₹" + pendingTotal(loans) + " pending)")
|
|
|
491 |
.append("\n\nRegards,\nSmartDukaan");
|
|
|
492 |
mailOutboxService.queueMail(to.toArray(new String[0]), null, subject, body.toString(),
|
|
|
493 |
"StoreStatusService.sendStatusChangeMail");
|
|
|
494 |
}
|
|
|
495 |
|
|
|
496 |
private void requireStatus(FofoStore fofoStore, StoreStatus required, String verb) throws ProfitMandiBusinessException {
|
|
|
497 |
if (fofoStore.getStoreStatus() != required) {
|
|
|
498 |
throw new ProfitMandiBusinessException("Store Status", "Not Allowed", "Cannot " + verb + " store "
|
|
|
499 |
+ fofoStore.getCode() + ": it is " + fofoStore.getStoreStatus() + ", not " + required);
|
|
|
500 |
}
|
|
|
501 |
}
|
|
|
502 |
|
|
|
503 |
/** Rounded to the rupee; read without creating a wallet row. */
|
|
|
504 |
private long walletBalance(int fofoId) throws ProfitMandiBusinessException {
|
|
|
505 |
UserWallet wallet = userWalletRepository.selectByRetailerId(fofoId);
|
|
|
506 |
return wallet == null ? 0 : Math.round(userWalletHistoryRepository.selectSumByWallet(wallet.getId()));
|
|
|
507 |
}
|
|
|
508 |
|
|
|
509 |
private static long pendingTotal(List<Loan> loans) {
|
|
|
510 |
return loans.stream().map(Loan::getPendingAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)
|
|
|
511 |
.setScale(0, BigDecimal.ROUND_HALF_UP).longValue();
|
|
|
512 |
}
|
|
|
513 |
|
|
|
514 |
private boolean holdsAccountsLevel(String email, EscalationType minimum) throws ProfitMandiBusinessException {
|
|
|
515 |
return holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS, minimum);
|
|
|
516 |
}
|
|
|
517 |
|
|
|
518 |
private boolean holdsLevel(String email, int categoryId, EscalationType minimum) throws ProfitMandiBusinessException {
|
|
|
519 |
AuthUser authUser;
|
|
|
520 |
try {
|
|
|
521 |
authUser = authRepository.selectByEmailOrMobile(email);
|
|
|
522 |
} catch (ProfitMandiBusinessException e) {
|
|
|
523 |
return false;
|
|
|
524 |
}
|
|
|
525 |
if (!Boolean.TRUE.equals(authUser.getActive())) {
|
|
|
526 |
return false;
|
|
|
527 |
}
|
|
|
528 |
return positionRepository.selectPositionByAuthId(authUser.getId()).stream()
|
|
|
529 |
.anyMatch(x -> x.getCategoryId() == categoryId
|
|
|
530 |
&& x.getEscalationType() != null && x.getEscalationType().compareTo(minimum) >= 0);
|
|
|
531 |
}
|
|
|
532 |
|
|
|
533 |
private Set<String> accountsEmailsFrom(EscalationType minimum) {
|
|
|
534 |
List<Integer> authIds = positionRepository.selectPositionByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS)
|
|
|
535 |
.stream().filter(x -> x.getAuthUserId() > 0 && x.getEscalationType() != null
|
|
|
536 |
&& x.getEscalationType().compareTo(minimum) >= 0)
|
|
|
537 |
.map(Position::getAuthUserId).distinct().collect(Collectors.toList());
|
|
|
538 |
if (authIds.isEmpty()) {
|
|
|
539 |
return new LinkedHashSet<>();
|
|
|
540 |
}
|
|
|
541 |
return authRepository.selectByIds(authIds).stream().filter(x -> Boolean.TRUE.equals(x.getActive()))
|
|
|
542 |
.map(AuthUser::getEmailId).filter(Objects::nonNull).map(String::toLowerCase)
|
|
|
543 |
.collect(Collectors.toCollection(LinkedHashSet::new));
|
|
|
544 |
}
|
|
|
545 |
|
|
|
546 |
private static EscalationType[] levelsFrom(EscalationType minimum) {
|
|
|
547 |
return Arrays.stream(EscalationType.values()).filter(x -> x.compareTo(minimum) >= 0).toArray(EscalationType[]::new);
|
|
|
548 |
}
|
|
|
549 |
|
|
|
550 |
private static String normalise(String emailId) {
|
|
|
551 |
return emailId == null ? "" : emailId.trim().toLowerCase();
|
|
|
552 |
}
|
|
|
553 |
|
|
|
554 |
private static ProfitMandiBusinessException denied(String action) {
|
|
|
555 |
return new ProfitMandiBusinessException("Access Denied", "Unauthorised Access", "You are not authorise to " + action);
|
|
|
556 |
}
|
|
|
557 |
|
|
|
558 |
private static String escape(String text) {
|
|
|
559 |
return text.replace("&", "&").replace("<", "<").replace(">", ">");
|
|
|
560 |
}
|
|
|
561 |
|
|
|
562 |
private interface PermissionCheck {
|
|
|
563 |
String check() throws ProfitMandiBusinessException;
|
|
|
564 |
}
|
|
|
565 |
|
|
|
566 |
private static boolean isAllowed(PermissionCheck check) {
|
|
|
567 |
try {
|
|
|
568 |
check.check();
|
|
|
569 |
return true;
|
|
|
570 |
} catch (ProfitMandiBusinessException e) {
|
|
|
571 |
return false;
|
|
|
572 |
}
|
|
|
573 |
}
|
|
|
574 |
}
|