Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
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
}