PurchaseSessionManager.java

/*
 * Copyright (c) 2002-2020, City of Paris
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *  1. Redistributions of source code must retain the above copyright notice
 *     and the following disclaimer.
 *
 *  2. Redistributions in binary form must reproduce the above copyright notice
 *     and the following disclaimer in the documentation and/or other materials
 *     provided with the distribution.
 *
 *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
 *     contributors may be used to endorse or promote products derived from
 *     this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * License 1.0
 */
package fr.paris.lutece.plugins.stock.service;

import fr.paris.lutece.plugins.stock.business.purchase.IPurchaseDTO;
import fr.paris.lutece.plugins.stock.business.purchase.exception.PurchaseSessionExpired;
import fr.paris.lutece.plugins.stock.business.purchase.exception.PurchaseUnavailable;
import fr.paris.lutece.plugins.stock.utils.DateUtils;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.inject.Inject;
import javax.inject.Named;

import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Logger;

/**
 * Singleton spring. Store active purchase (not yet stored into database) and allow to reserve some.
 * 
 * @author aBataille
 */
public class PurchaseSessionManager implements IPurchaseSessionManager
{
    public static final Logger LOG = Logger.getLogger( PurchaseSessionManager.class );
    private final SimpleDateFormat _format = new SimpleDateFormat( "dd/MM/yyyy HH:mm" );

    @Inject
    @Named( "stock.offerService" )
    private IOfferService _offerService;

    /**
     * Purchase idle quantity by offer
     */
    private Map<Integer, Integer> _idleQuantity = new HashMap<>( );

    /**
     * Store active purchases by session id
     */
    private Map<String, List<IPurchaseDTO>> _activePurchaseBySession = new HashMap<>( );

    /*
     * (non-Javadoc)
     * 
     * @see fr.paris.lutece.plugins.stock.service.IPurchaseSession#reserve(fr.paris .lutece.plugins.stock.business.purchase.IPurchaseDTO, java.lang.Long)
     */
    /**
     * {@inheritDoc}
     */
    public void reserve( String sessionId, IPurchaseDTO purchase ) throws PurchaseUnavailable
    {
        synchronized( _activePurchaseBySession )
        {
            Integer offerId = purchase.getOfferId( );
            // place restantes
            Integer qttInDb = _offerService.getQuantity( offerId );
            // place actuellement reservé en session
            Integer qttIdle = _idleQuantity.get( offerId );
            Integer qttAvailable;

            if ( qttIdle == null )
            {
                qttIdle = 0;
            }

            // Quantité disponible = quantité en base - quantité réservée en attente
            qttAvailable = qttInDb - qttIdle;

            if ( ( qttAvailable - purchase.getQuantity( ) ) < 0 )
            {
                throw new PurchaseUnavailable( offerId, "Quantité restante insuffisante (" + qttAvailable + ")" );
            }
            // Quantité disponible ok
            qttIdle = qttIdle + purchase.getQuantity( );
            _idleQuantity.put( offerId, qttIdle );

            // Ajout de l'achat dans la liste
            addPurchase( sessionId, purchase );
        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see fr.paris.lutece.plugins.stock.service.IPurchaseSession#hasReserved(java .lang.Long, fr.paris.lutece.plugins.stock.business.purchase.IPurchaseDTO)
     */
    /**
     * {@inheritDoc}
     */
    public void checkReserved( String sessionId, IPurchaseDTO purchase ) throws PurchaseSessionExpired
    {
        LOG.debug( "Call checkReserved" );
        boolean hasReserved = false;
        synchronized( _activePurchaseBySession )
        {
            if ( _activePurchaseBySession.get( sessionId ) != null )
            {
                for ( IPurchaseDTO purchaseIdle : _activePurchaseBySession.get( sessionId ) )
                {
                    if ( purchaseIdle.getOfferId( ).equals( purchase.getOfferId( ) ) && purchaseIdle.getQuantity( ).equals( purchase.getQuantity( ) ) )
                    {
                        hasReserved = true;
                    }
                }
            }
            if ( !hasReserved )
            {
                throw new PurchaseSessionExpired( purchase.getOfferId( ), "Aucune session d'achat trouvée (sid=" + sessionId + ", id offre="
                        + purchase.getOfferId( ) + ", qtt=" + purchase.getQuantity( ) + ")" );
            }
        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see fr.paris.lutece.plugins.stock.service.IPurchaseSession#release(java.lang .Long, fr.paris.lutece.plugins.stock.business.purchase.IPurchaseDTO)
     */
    /**
     * {@inheritDoc}
     */
    public synchronized void release( String sessionId, IPurchaseDTO purchase )
    {
        LOG.debug( "Call release" );
        synchronized( _activePurchaseBySession )
        {
            if ( _activePurchaseBySession.get( sessionId ) != null )
            {
                Iterator<IPurchaseDTO> itIdlePurchase = _activePurchaseBySession.get( sessionId ).iterator( );
                IPurchaseDTO purchaseIdle;
                while ( itIdlePurchase.hasNext( ) )
                {
                    purchaseIdle = itIdlePurchase.next( );
                    if ( purchaseIdle.getOfferId( ).equals( purchase.getOfferId( ) ) )
                    {
                        deleteFromSession( itIdlePurchase, purchaseIdle );
                        break;
                    }
                }
            }
        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see fr.paris.lutece.plugins.stock.service.IPurchaseSession#releaseAll(java .lang.Long)
     */
    /**
     * {@inheritDoc}
     */
    public synchronized void releaseAll( String sessionId )
    {
        LOG.debug( "Call releaseAll" );
        synchronized( _activePurchaseBySession )
        {
            if ( _activePurchaseBySession.get( sessionId ) != null )
            {
                Iterator<IPurchaseDTO> itIdlePurchase = _activePurchaseBySession.get( sessionId ).iterator( );
                IPurchaseDTO purchaseIdle;
                while ( itIdlePurchase.hasNext( ) )
                {
                    purchaseIdle = itIdlePurchase.next( );
                    deleteFromSession( itIdlePurchase, purchaseIdle );
                }
            }
            _activePurchaseBySession.remove( sessionId );
        }
    }

    /**
     * Add purchase for user
     * 
     * @param sessionId
     *            session id
     * @param purchase
     *            purchase
     */
    private void addPurchase( String sessionId, IPurchaseDTO purchase )
    {
        LOG.debug( "Call addPurchase" );
        synchronized( _activePurchaseBySession )
        {
            if ( _activePurchaseBySession.get( sessionId ) == null )
            {
                _activePurchaseBySession.put( sessionId, new ArrayList<IPurchaseDTO>( ) );
            }
            else
            {
                // Si un achat sur la même offre est déjà en attente on le supprime
                for ( IPurchaseDTO purchaseIdle : _activePurchaseBySession.get( sessionId ) )
                {
                    if ( purchaseIdle.getOfferId( ).equals( purchase.getOfferId( ) ) )
                    {
                        if ( LOG.isDebugEnabled( ) )
                        {
                            LOG.debug( "Achat pour le produit id " + purchase.getOfferId( ) + " déjà en cours sur la session " + sessionId
                                    + " - suppression de l'achat en attente" );
                        }
                        release( sessionId, purchase );
                        break;
                    }
                }
            }

            _activePurchaseBySession.get( sessionId ).add( purchase );
        }
    }

    @Override
    public Integer updateQuantityWithSession( Integer quantity, Integer offerId )
    {
        // quantité actuellement reservé en session
        Integer qttIdle = _idleQuantity.get( offerId );
        int quantityCopie = quantity;

        // si il existe en session une quantité déjà reservé pour l'offre, on doit la retirer de la quantité disponible pour l'offre
        if ( qttIdle != null )
        {
            quantityCopie -= qttIdle;
        }

        if ( quantity < 0 )
        {
            quantityCopie = 0;
        }

        return quantityCopie;
    }

    @Override
    public void clearPurchase( Integer minutes )
    {
        LOG.debug( "Call clearPurchase" );
        synchronized( _activePurchaseBySession )
        {
            // itération des liste de réservations pour chaque session
            for ( Entry<String, List<IPurchaseDTO>> entry : _activePurchaseBySession.entrySet( ) )
            {
                String idSession = entry.getKey( );
                Iterator<IPurchaseDTO> iterator = _activePurchaseBySession.get( idSession ).iterator( );
                while ( iterator.hasNext( ) )
                {
                    IPurchaseDTO purchaseControled = iterator.next( );
                    if ( !shouldBeKeep( purchaseControled, minutes ) )
                    {
                        deleteFromSession( iterator, purchaseControled );
                        LOG.debug( "Suppression de la reservation pour l'offre d'id " + purchaseControled.getOfferId( ) + " de l'utilisateur "
                                + purchaseControled.getUserName( ) );
                    }
                }
            }
        }
    }

    /**
     * Remove purchase from session manager
     * 
     * @param iterator
     *            the iterator with purchase
     * @param purchaseControled
     *            the purchase
     */
    private void deleteFromSession( Iterator<IPurchaseDTO> iterator, IPurchaseDTO purchaseControled )
    {
        _idleQuantity.put( purchaseControled.getOfferId( ), _idleQuantity.get( purchaseControled.getOfferId( ) ) - purchaseControled.getQuantity( ) );
        iterator.remove( );
    }

    /**
     * Check if a purchase should be keep in session
     * 
     * @param purchase
     *            the purchase to check
     * @param minutes
     *            the number max of minutes to keep purchase
     * @return true to keep, false to remove
     */
    private boolean shouldBeKeep( IPurchaseDTO purchase, Integer minutes )
    {
        boolean toKeep = true;
        String dateCreate = purchase.getDate( );
        String hourCreate = purchase.getHeure( );
        // si ces deux attributs ne sont pas présent, la reservation n'a pas été faite sur le FO et n'est donc pas concernée.
        if ( StringUtils.isNotBlank( dateCreate ) && StringUtils.isNotBlank( hourCreate ) )
        {
            try
            {
                Date datePurchase = _format.parse( dateCreate + " " + hourCreate );
                Date currentDate = DateUtils.getCurrentDate( );
                GregorianCalendar calendarPurchase = new GregorianCalendar( );
                calendarPurchase.setTime( datePurchase );
                calendarPurchase.add( Calendar.MINUTE, minutes );
                if ( calendarPurchase.getTime( ).before( currentDate ) )
                {
                    toKeep = false;
                }
            }
            catch( ParseException e )
            {
                LOG.error( "Erreur de conversion de string => date : " + e );
            }
        }
        return toKeep;
    }
}