Blame | Last modification | View Log | RSS feed
package com.spice.profitmandi.service.user;import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;import com.spice.profitmandi.common.model.ProfitMandiConstants;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.cs.Position;import com.spice.profitmandi.dao.entity.fofo.FofoStore;import com.spice.profitmandi.dao.entity.fofo.StoreRevival;import com.spice.profitmandi.dao.entity.fofo.StoreStatusLog;import com.spice.profitmandi.dao.entity.transaction.Loan;import com.spice.profitmandi.dao.entity.transaction.UserWallet;import com.spice.profitmandi.dao.enumuration.cs.EscalationType;import com.spice.profitmandi.dao.enumuration.fofo.StoreRevivalType;import com.spice.profitmandi.dao.enumuration.fofo.StoreStatus;import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusAction;import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusSource;import com.spice.profitmandi.dao.repository.auth.AuthRepository;import com.spice.profitmandi.dao.repository.cs.CsService;import com.spice.profitmandi.dao.repository.cs.PositionRepository;import com.spice.profitmandi.dao.repository.dtr.FofoStoreRepository;import com.spice.profitmandi.dao.repository.fofo.StoreRevivalRepository;import com.spice.profitmandi.dao.repository.fofo.StoreStatusLogRepository;import com.spice.profitmandi.dao.repository.transaction.LoanRepository;import com.spice.profitmandi.dao.repository.transaction.UserWalletHistoryRepository;import com.spice.profitmandi.dao.repository.transaction.UserWalletRepository;import com.spice.profitmandi.service.mail.MailOutboxService;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.math.BigDecimal;import java.time.LocalDate;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.time.temporal.ChronoUnit;import java.util.*;import java.util.stream.Collectors;/*** The only writer of a partner store's status (fofo_store.store_status, plus the legacy active / closed columns).* Every change checks the allowed transitions, who may make it and the mandatory reason, then writes a* fofo.store_status_log row. Pause, un-pause and suspend mail Tarun, Kamini and the partner's Sales L5 and above.*/@Servicepublic class StoreStatusService {private static final Logger LOGGER = LogManager.getLogger(StoreStatusService.class);static final String TARUN = "tarun.verma@smartdukaan.com";static final String KAMINI = "kamini.sharma@smartdukaan.com";static final String NEERAJ = "neeraj.gupta@smartdukaan.com";private static final List<String> NAMED = Collections.unmodifiableList(Arrays.asList(TARUN, KAMINI));public static final String ALLOWED_NAMED = "NAMED";public static final String ALLOWED_SALES_L5 = "SALES_L5+";public static final String ALLOWED_ACCOUNTS_L3 = "ACCOUNTS_L3+";public static final String ALLOWED_ACCOUNTS_L4 = "ACCOUNTS_L4+";public static final String ALLOWED_SYSTEM = "SYSTEM";public static final String ALLOWED_LOI = "LOI_APPROVAL";private static final int MAX_REASON = 1000;private static final int CLOSURE_REMINDER_DAYS = 7;private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm a");private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy");@Autowiredprivate FofoStoreRepository fofoStoreRepository;@Autowiredprivate StoreStatusLogRepository storeStatusLogRepository;@Autowiredprivate StoreRevivalRepository storeRevivalRepository;@Autowiredprivate AuthRepository authRepository;@Autowiredprivate PositionRepository positionRepository;@Autowiredprivate CsService csService;@Autowiredprivate UserWalletRepository userWalletRepository;@Autowiredprivate UserWalletHistoryRepository userWalletHistoryRepository;@Autowiredprivate LoanRepository loanRepository;@Autowiredprivate MailOutboxService mailOutboxService;// ── Status changes ────────────────────────────────────────────────────────────────────────────────────────/** ACTIVE → PAUSED (Tarun, Kamini, Sales L5+ for the partner) or SUSPENDED → PAUSED (activation rights). */public void pause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);String allowedAs = fofoStore.getStoreStatus() == StoreStatus.ACTIVE? allowedToPause(fofoId, changedBy) : allowedToChange(changedBy);changeStatus(fofoStore, StoreStatus.PAUSED, StoreStatusAction.PAUSE, reason, changedBy, allowedAs, source, null, null);sendStatusChangeMail(fofoStore, StoreStatusAction.PAUSE, reason, changedBy);}/** PAUSED → ACTIVE. */public void unpause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);requireStatus(fofoStore, StoreStatus.PAUSED, "un-pause");String allowedAs = allowedToChange(changedBy);changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.UNPAUSE, reason, changedBy, allowedAs, source, null, null);sendStatusChangeMail(fofoStore, StoreStatusAction.UNPAUSE, reason, changedBy);}/** ACTIVE or PAUSED → SUSPENDED (nearing closure, logged out). */public void suspend(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);String allowedAs = allowedToChange(changedBy);changeStatus(fofoStore, StoreStatus.SUSPENDED, StoreStatusAction.SUSPEND, reason, changedBy, allowedAs, source, null, null);sendStatusChangeMail(fofoStore, StoreStatusAction.SUSPEND, reason, changedBy);}/** SUSPENDED → ACTIVE for good (a PAUSED store is un-paused instead). Ends any temporary activation. */public void activate(int fofoId, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);String allowedAs = allowedToChange(changedBy);if (fofoStore.getStoreStatus() == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {// Temporarily active: make it permanent.StoreStatus previous = fofoStore.getStoreStatus();fofoStore.setActiveTimeStamp(null);writeLog(fofoStore, previous, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);return;}requireStatus(fofoStore, StoreStatus.SUSPENDED, "activate");changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);}/*** PAUSED or SUSPENDED → ACTIVE until the start of today + {@code days}. The early-morning job then returns the* store to the status it had before.*/public void activateTemporarily(int fofoId, int days, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {if (days <= 0) {throw new ProfitMandiBusinessException("Temporary Activation", "Invalid Days", "Please enter a number of days above zero");}FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);StoreStatus current = fofoStore.getStoreStatus();if (current != StoreStatus.PAUSED && current != StoreStatus.SUSPENDED) {throw new ProfitMandiBusinessException("Temporary Activation", "Not Allowed","Only a paused or suspended store can be activated temporarily; this store is " + current);}String allowedAs = allowedToChange(changedBy);LocalDateTime activeUntil = LocalDate.now().atStartOfDay().plusDays(days);changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.TEMP_ACTIVATE, null, changedBy, allowedAs, source, activeUntil, null);fofoStore.setActiveTimeStamp(activeUntil);}/** Cron: every temporarily active store whose period has ended goes back to the status it had before. */public int expireTemporaryActivations() throws ProfitMandiBusinessException {LocalDateTime now = LocalDateTime.now();int expired = 0;for (FofoStore fofoStore : fofoStoreRepository.selectByStoreStatus(StoreStatus.ACTIVE)) {if (fofoStore.getActiveTimeStamp() == null || fofoStore.getActiveTimeStamp().isAfter(now)) {continue;}StoreStatusLog tempLog = storeStatusLogRepository.selectLatest(fofoStore.getId(), StoreStatusAction.TEMP_ACTIVATE);StoreStatus previous = tempLog != null && tempLog.getFromStatus() != null ? tempLog.getFromStatus() : StoreStatus.SUSPENDED;changeStatus(fofoStore, previous, StoreStatusAction.TEMP_EXPIRED, null, "checkPartnerActiveStore",ALLOWED_SYSTEM, StoreStatusSource.CRON, null, null);expired++;}LOGGER.info("Temporary activations expired: {}", expired);return expired;}// ── Closure ───────────────────────────────────────────────────────────────────────────────────────────────/*** Conditions not met for closing this store, empty when it can be closed: SUSPENDED, wallet at ₹0 and no unsettled* loan (limit blocks included). Who is closing is checked separately by {@link #allowedToClose(String)}.*/public List<String> closeBlockers(FofoStore fofoStore) throws ProfitMandiBusinessException {List<String> blockers = new ArrayList<>();if (fofoStore.getStoreStatus() != StoreStatus.SUSPENDED) {blockers.add("Store must be suspended first (it is " + fofoStore.getStoreStatus() + ")");}long wallet = walletBalance(fofoStore.getId());if (wallet != 0) {blockers.add("Wallet balance must be ₹0 (it is ₹" + wallet + ")");}List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());if (!loans.isEmpty()) {blockers.add("All loans must be settled (" + loans.size() + " open, ₹" + pendingTotal(loans) + " pending)");}return blockers;}/** Throws unless the store can be closed by {@code closedBy}; returns the role that allows it. */public String assertCanClose(FofoStore fofoStore, String closedBy) throws ProfitMandiBusinessException {String allowedAs = allowedToClose(closedBy);if (fofoStore.isClosed()) {throw new ProfitMandiBusinessException("Store Closed", "Store Already Closed", "This store is already permanently closed");}List<String> blockers = closeBlockers(fofoStore);if (!blockers.isEmpty()) {throw new ProfitMandiBusinessException("Store Closure", "Closure Not Allowed", String.join("; ", blockers));}return allowedAs;}/** SUSPENDED → CLOSED. Called by StoreClosureService once the closure record exists. */void markClosed(FofoStore fofoStore, String remark, String closedBy, String allowedAs, StoreStatusSource source,int storeClosureId) throws ProfitMandiBusinessException {changeStatus(fofoStore, StoreStatus.CLOSED, StoreStatusAction.CLOSE, remark, closedBy, allowedAs, source, null, storeClosureId);fofoStore.setClosedTimestamp(LocalDateTime.now());}// ── Revival ───────────────────────────────────────────────────────────────────────────────────────────────/** Only SUSPENDED (same store) and CLOSED (new store) partners can be revived. */public void assertRevivable(FofoStore fofoStore) throws ProfitMandiBusinessException {StoreStatus status = fofoStore.getStoreStatus();if (status != StoreStatus.SUSPENDED && status != StoreStatus.CLOSED) {throw new ProfitMandiBusinessException("Revival", "Not Allowed", "Store " + fofoStore.getCode() + " is " + status+ ". Only suspended or closed stores can be revived; reactivate or un-pause it instead.");}}/** SUSPENDED → ACTIVE with the same code, once the revival payment is approved. */public void reviveSameStore(int fofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);requireStatus(fofoStore, StoreStatus.SUSPENDED, "revive in place");changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.REVIVAL, null, revivedBy, ALLOWED_LOI,StoreStatusSource.LOI_REVIVAL, null, null);persistRevival(fofoId, fofoId, StoreRevivalType.SAME_STORE, loiId, revivedBy);}/** A CLOSED partner was given a new store (new code); the old one stays CLOSED. Bookkeeping only. */public void recordNewStoreRevival(int oldFofoId, int newFofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {FofoStore oldStore = fofoStoreRepository.selectByRetailerId(oldFofoId);requireStatus(oldStore, StoreStatus.CLOSED, "revive as a new store");FofoStore newStore = fofoStoreRepository.selectByRetailerId(newFofoId);writeLog(newStore, null, StoreStatusAction.REVIVAL, "New store for closed " + oldStore.getCode(), revivedBy,ALLOWED_LOI, StoreStatusSource.LOI_REVIVAL, null, null);persistRevival(oldFofoId, newFofoId, StoreRevivalType.NEW_STORE, loiId, revivedBy);}// ── Wallet ────────────────────────────────────────────────────────────────────────────────────────────────/** Call at partner-initiated spends only; system, admin and cron debits must not call this. */public void assertCanSpendWallet(int fofoId) throws ProfitMandiBusinessException {FofoStore fofoStore;try {fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);} catch (ProfitMandiBusinessException e) {return; // a retailer without a partner store}StoreStatus status = fofoStore.getStoreStatus();if (!status.canSpendWallet()) {throw new ProfitMandiBusinessException("Wallet", "Wallet Blocked", status == StoreStatus.PAUSED? "Your store is paused, so wallet payments are not allowed. Please contact your sales manager.": "Your store is " + status.name().toLowerCase() + ", so wallet payments are not allowed.");}}// ── History and permissions for the UI ────────────────────────────────────────────────────────────────────public List<StoreStatusLog> history(int fofoId) {return storeStatusLogRepository.selectByFofoId(fofoId);}/** Actions {@code emailId} may take on this store right now (FSM and permission; closure conditions excluded). */public Set<StoreStatusAction> allowedActions(FofoStore fofoStore, String emailId) {Set<StoreStatusAction> actions = EnumSet.noneOf(StoreStatusAction.class);StoreStatus status = fofoStore.getStoreStatus();boolean change = isAllowed(() -> allowedToChange(emailId));if (status == StoreStatus.ACTIVE && isAllowed(() -> allowedToPause(fofoStore.getId(), emailId))|| status == StoreStatus.SUSPENDED && change) {actions.add(StoreStatusAction.PAUSE);}if (change) {if (status == StoreStatus.PAUSED) {actions.add(StoreStatusAction.UNPAUSE);}if (status == StoreStatus.ACTIVE || status == StoreStatus.PAUSED) {actions.add(StoreStatusAction.SUSPEND);}if (status == StoreStatus.SUSPENDED || status == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {actions.add(StoreStatusAction.ACTIVATE);}if (status == StoreStatus.PAUSED || status == StoreStatus.SUSPENDED) {actions.add(StoreStatusAction.TEMP_ACTIVATE);}}if (status == StoreStatus.SUSPENDED && isAllowed(() -> allowedToClose(emailId))) {actions.add(StoreStatusAction.CLOSE);}return actions;}/*** Which buttons a store list may show to {@code emailId}: keys pause, change and close. Pause is only a hint here* (Sales L5+ anywhere); each action re-checks the partner coverage itself.*/public Map<String, Boolean> listPermissions(String emailId) throws ProfitMandiBusinessException {String email = normalise(emailId);Map<String, Boolean> permissions = new HashMap<>();permissions.put("pause", NAMED.contains(email)|| holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_SALES, EscalationType.L5));permissions.put("change", isAllowed(() -> allowedToChange(email)));permissions.put("close", isAllowed(() -> allowedToClose(email)));return permissions;}/** Pause: Tarun, Kamini, or Sales L5 and above who cover this partner. */public String allowedToPause(int fofoId, String emailId) throws ProfitMandiBusinessException {String email = normalise(emailId);if (NAMED.contains(email)) {return ALLOWED_NAMED;}boolean coversPartner = csService.getAuthUsersByPartnerIdAndCategory(fofoId, ProfitMandiConstants.TICKET_CATEGORY_SALES,levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull).anyMatch(x -> x.equalsIgnoreCase(email));if (coversPartner) {return ALLOWED_SALES_L5;}throw denied("pause this store");}/** Suspend, activate, un-pause, temporary activate: Tarun, Kamini, Neeraj, or Accounts L4 and above. */public String allowedToChange(String emailId) throws ProfitMandiBusinessException {String email = normalise(emailId);if (NAMED.contains(email) || NEERAJ.equals(email)) {return ALLOWED_NAMED;}if (holdsAccountsLevel(email, EscalationType.L4)) {return ALLOWED_ACCOUNTS_L4;}throw denied("change this store's status");}/** Close: Tarun, Kamini, or Accounts L3 and above. */public String allowedToClose(String emailId) throws ProfitMandiBusinessException {String email = normalise(emailId);if (NAMED.contains(email)) {return ALLOWED_NAMED;}if (holdsAccountsLevel(email, EscalationType.L3)) {return ALLOWED_ACCOUNTS_L3;}throw denied("close store");}// ── Weekly closure reminder ───────────────────────────────────────────────────────────────────────────────/*** Monday digest of stores SUSPENDED for more than 7 days, To Neeraj with the other closers in CC. Stores whose* only entry is the migration baseline were already inactive before the log existed, so they are always listed.*/public int sendClosureReminder() throws ProfitMandiBusinessException {List<FofoStore> suspended = fofoStoreRepository.selectByStoreStatus(StoreStatus.SUSPENDED);Map<Integer, StoreStatusLog> entries = storeStatusLogRepository.selectLatestEntries(suspended.stream().map(FofoStore::getId).collect(Collectors.toList()), StoreStatus.SUSPENDED);LocalDateTime cutoff = LocalDateTime.now().minusDays(CLOSURE_REMINDER_DAYS);StringBuilder rows = new StringBuilder();int listed = 0;int closable = 0;for (FofoStore fofoStore : suspended) {StoreStatusLog entry = entries.get(fofoStore.getId());boolean baseline = entry == null || entry.getAction() == StoreStatusAction.BASELINE;if (!baseline && entry.getCreateTimestamp().isAfter(cutoff)) {continue;}long wallet = walletBalance(fofoStore.getId());List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());boolean canClose = wallet == 0 && loans.isEmpty();String since = baseline? "before " + (entry == null ? "-" : entry.getCreateTimestamp().format(DATE_FORMAT)): entry.getCreateTimestamp().format(DATE_FORMAT) + " ("+ ChronoUnit.DAYS.between(entry.getCreateTimestamp().toLocalDate(), LocalDate.now()) + " days)";rows.append("<tr><td>").append(fofoStore.getCode()).append("</td><td>").append(escape(Optional.ofNullable(fofoStore.getOutletName()).orElse("-"))).append("</td><td>").append(since).append("</td><td align=\"right\">").append(wallet).append("</td><td align=\"right\">").append(loans.isEmpty() ? "-" : loans.size() + " / ₹" + pendingTotal(loans)).append("</td><td>").append(canClose ? "<b>Yes</b>" : "No").append("</td></tr>");listed++;if (canClose) {closable++;}}if (listed == 0) {LOGGER.info("Closure reminder: no store suspended for more than {} days", CLOSURE_REMINDER_DAYS);return 0;}Set<String> cc = new LinkedHashSet<>(NAMED);cc.addAll(accountsEmailsFrom(EscalationType.L3));cc.remove(NEERAJ);String subject = "Suspended stores pending closure - " + listed + " stores, " + closable + " closable now";String body = "<p>Dear Neeraj,</p><p>These stores have been suspended for more than " + CLOSURE_REMINDER_DAYS+ " days. A store can be closed once its wallet is at ₹0 and every loan, including limit blocks, is settled.</p>"+ "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\" style=\"border-collapse:collapse\">"+ "<tr><th>Store</th><th>Name</th><th>Suspended since</th><th>Wallet ₹</th><th>Open loans</th><th>Closable now</th></tr>"+ rows + "</table><p>Regards,<br>SmartDukaan</p>";mailOutboxService.queueMail(new String[]{NEERAJ}, cc.toArray(new String[0]), null, subject, body, true,"StoreStatusService.sendClosureReminder");LOGGER.info("Closure reminder queued: {} stores, {} closable", listed, closable);return listed;}// ── Internals ─────────────────────────────────────────────────────────────────────────────────────────────private void changeStatus(FofoStore fofoStore, StoreStatus target, StoreStatusAction action, String reason,String changedBy, String allowedAs, StoreStatusSource source, LocalDateTime activeUntil,Integer storeClosureId) throws ProfitMandiBusinessException {StoreStatus from = fofoStore.getStoreStatus();if (!from.canMoveTo(target)) {throw new ProfitMandiBusinessException("Store Status", "Not Allowed","Store " + fofoStore.getCode() + " cannot move from " + from + " to " + target);}String cleanReason = reason == null ? null : reason.trim();if (action.needsReason() && (cleanReason == null || cleanReason.isEmpty())) {throw new ProfitMandiBusinessException("Store Status", "Reason Missing","Please give a reason to " + action.getLabel().toLowerCase() + " this store");}if (cleanReason != null && cleanReason.length() > MAX_REASON) {throw new ProfitMandiBusinessException("Store Status", "Reason Too Long","Please keep the reason within " + MAX_REASON + " characters");}fofoStore.setStoreStatus(target);if (action != StoreStatusAction.TEMP_ACTIVATE) {fofoStore.setActiveTimeStamp(null);}writeLog(fofoStore, from, action, cleanReason, changedBy, allowedAs, source, activeUntil, storeClosureId);LOGGER.info("Store {} {} -> {} ({}) by {} as {}", fofoStore.getCode(), from, target, action, changedBy, allowedAs);}private void writeLog(FofoStore fofoStore, StoreStatus from, StoreStatusAction action, String reason, String changedBy,String allowedAs, StoreStatusSource source, LocalDateTime activeUntil, Integer storeClosureId) {StoreStatusLog log = new StoreStatusLog();log.setFofoId(fofoStore.getId());log.setFromStatus(from);log.setToStatus(fofoStore.getStoreStatus());log.setAction(action);log.setReason(reason);log.setSource(source);log.setChangedBy(changedBy);log.setAllowedAs(allowedAs);log.setActiveUntil(activeUntil);log.setStoreClosureId(storeClosureId);log.setCreateTimestamp(LocalDateTime.now());storeStatusLogRepository.persist(log);}private void persistRevival(int oldFofoId, int newFofoId, StoreRevivalType type, Integer loiId, String revivedBy) {StoreRevival revival = new StoreRevival();revival.setOldFofoId(oldFofoId);revival.setNewFofoId(newFofoId);revival.setRevivalType(type);revival.setLoiId(loiId);revival.setRevivedBy(revivedBy);revival.setCreateTimestamp(LocalDateTime.now());storeRevivalRepository.persist(revival);LOGGER.info("Store revival recorded: {}", revival);}private void sendStatusChangeMail(FofoStore fofoStore, StoreStatusAction action, String reason, String changedBy)throws ProfitMandiBusinessException {Set<String> to = new LinkedHashSet<>(NAMED);csService.getAuthUsersByPartnerIdAndCategory(fofoStore.getId(), ProfitMandiConstants.TICKET_CATEGORY_SALES,levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull).map(String::toLowerCase).forEach(to::add);StoreStatusLog log = storeStatusLogRepository.selectLatest(fofoStore.getId(), action);List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());String name = Optional.ofNullable(fofoStore.getOutletName()).orElse("-");String subject = "Store " + action.getLabel() + " - " + fofoStore.getCode() + " - " + name;StringBuilder body = new StringBuilder();body.append("Dear Team,\n\nA partner store's status has changed.\n\n");body.append("Partner : ").append(name).append(" (Partner Id ").append(fofoStore.getId()).append(")\n");body.append("Store Code : ").append(fofoStore.getCode()).append("\n");body.append("Status : ").append(log == null ? "-" : log.getFromStatus()).append(" -> ").append(fofoStore.getStoreStatus()).append("\n");body.append("Reason : ").append(reason.trim()).append("\n");body.append("Changed By : ").append(changedBy).append("\n");body.append("Changed On : ").append(LocalDateTime.now().format(TIME_FORMAT)).append("\n");body.append("Wallet : ₹").append(walletBalance(fofoStore.getId())).append("\n");body.append("Open Loans : ").append(loans.isEmpty() ? "None" : loans.size() + " (₹" + pendingTotal(loans) + " pending)").append("\n\nRegards,\nSmartDukaan");mailOutboxService.queueMail(to.toArray(new String[0]), null, subject, body.toString(),"StoreStatusService.sendStatusChangeMail");}private void requireStatus(FofoStore fofoStore, StoreStatus required, String verb) throws ProfitMandiBusinessException {if (fofoStore.getStoreStatus() != required) {throw new ProfitMandiBusinessException("Store Status", "Not Allowed", "Cannot " + verb + " store "+ fofoStore.getCode() + ": it is " + fofoStore.getStoreStatus() + ", not " + required);}}/** Rounded to the rupee; read without creating a wallet row. */private long walletBalance(int fofoId) throws ProfitMandiBusinessException {UserWallet wallet = userWalletRepository.selectByRetailerId(fofoId);return wallet == null ? 0 : Math.round(userWalletHistoryRepository.selectSumByWallet(wallet.getId()));}private static long pendingTotal(List<Loan> loans) {return loans.stream().map(Loan::getPendingAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(0, BigDecimal.ROUND_HALF_UP).longValue();}private boolean holdsAccountsLevel(String email, EscalationType minimum) throws ProfitMandiBusinessException {return holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS, minimum);}private boolean holdsLevel(String email, int categoryId, EscalationType minimum) throws ProfitMandiBusinessException {AuthUser authUser;try {authUser = authRepository.selectByEmailOrMobile(email);} catch (ProfitMandiBusinessException e) {return false;}if (!Boolean.TRUE.equals(authUser.getActive())) {return false;}return positionRepository.selectPositionByAuthId(authUser.getId()).stream().anyMatch(x -> x.getCategoryId() == categoryId&& x.getEscalationType() != null && x.getEscalationType().compareTo(minimum) >= 0);}private Set<String> accountsEmailsFrom(EscalationType minimum) {List<Integer> authIds = positionRepository.selectPositionByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS).stream().filter(x -> x.getAuthUserId() > 0 && x.getEscalationType() != null&& x.getEscalationType().compareTo(minimum) >= 0).map(Position::getAuthUserId).distinct().collect(Collectors.toList());if (authIds.isEmpty()) {return new LinkedHashSet<>();}return authRepository.selectByIds(authIds).stream().filter(x -> Boolean.TRUE.equals(x.getActive())).map(AuthUser::getEmailId).filter(Objects::nonNull).map(String::toLowerCase).collect(Collectors.toCollection(LinkedHashSet::new));}private static EscalationType[] levelsFrom(EscalationType minimum) {return Arrays.stream(EscalationType.values()).filter(x -> x.compareTo(minimum) >= 0).toArray(EscalationType[]::new);}private static String normalise(String emailId) {return emailId == null ? "" : emailId.trim().toLowerCase();}private static ProfitMandiBusinessException denied(String action) {return new ProfitMandiBusinessException("Access Denied", "Unauthorised Access", "You are not authorise to " + action);}private static String escape(String text) {return text.replace("&", "&").replace("<", "<").replace(">", ">");}private interface PermissionCheck {String check() throws ProfitMandiBusinessException;}private static boolean isAllowed(PermissionCheck check) {try {check.check();return true;} catch (ProfitMandiBusinessException e) {return false;}}}