#------------------------------------------------------------
# Copyright (C) 2009 RoyaltyShare, Inc.   All Rights Reserved
#------------------------------------------------------------

package RPS::Mechanical::UK::Process::CreateStatement::Mcps;

use strict;
use lib '/app/tools/rps/lib';
use lib '/app/tools/raptor/lib';
use lib '/app/tools/common/lib';
use Common::Assert;
use Common::RSApp;
use Common::RSMath;
use Common::DB::Item::ClientMcpsSalesPricingMode;
use Common::DB::Item::McpsDefaultRoyaltyRate;

use Raptor::DB::Item::Sale;

use RPS::Product::Price;
use RPS::Mechanical::UK::Process::CreateStatement;
use RPS::DB::Item::ClientOptions;
use RPS::DB::Item::McpsLicense;
use RPS::DB::Item::McpsStatementItem;
use RPS::DB::Item::McpsStatement;
use RPS::DB::Item::Product;
use RPS::DB::Item::Album;
use RPS::DB::Item::Artist;
use RPS::DB::Item::Label;
use RPS::DB::Item::Channel;
use RPS::DB::Item::ProductPrice;
use RPS::DB::Item::ProductType;
use RPS::DB::Item::PriceLevel;
use RPS::DB::Item::Price;
use RPS::DB::Item::McpsExportUplift;
use RPS::DB::Item::McpsLicenseRetention;
use RPS::DB::Item::McpsLicenseCarryover;

use RPS::Mechanical::Process::SaleException;
use RPS::Mechanical::Process::Exception;

# This is the base for the DVD1 and AP1 statement processes, which are
# extremely similar.

use base 'RPS::Mechanical::UK::Process::CreateStatement';

sub _getStatementDBItem {
    my ($self) = @_;

    my $statement = RPS::DB::Item::McpsStatement->Lookup( mcps_statement_id => $self->_statementID() );
    return $statement;
}

# Static methods.
#
sub commit {
    my ($self) = @_;

    # Generally, I think the steps will be the same.
    #
    # I will need to iterate over all the statement items that belong to this statement
    # -> Fetching the items will probably be abstract.
    #
    my $allItems = RPS::DB::Item::McpsStatementItem->GetByMCPSStatementID( $self->_statementID() );
    while ( my $item = $allItems->next() ) {
        $self->_commitItem($item);
    }

}

sub _commitItem {
    my ( $self, $item ) = @_;

    # We're going to need the license data.
    #
    my $license = RPS::DB::Item::McpsLicense->Lookup( mcps_license_id => $item->mcps_license_id );

    # If we're holding retentions, create a new record for them.
    #
    # !!! We should only have 1 sort of retention being held.
    #
    my $retentionsHeld = $item->retentions();
    my $category       = RPS::DB::Item::McpsLicense::kRetentionCategoryStandard;
    if ( $item->tv_retentions() > 0 ) {
        $retentionsHeld = $item->tv_retentions();
        $category       = RPS::DB::Item::McpsLicense::kRetentionCategoryTVAdvertised;
    }

    if ( $retentionsHeld > 0 ) {

        # Figure out the correct period id, based on the number of completed
        # runs (which we have already incremented) and the initial value.
        #
        my $completedPeriods = _periodsCompletedForLicense($license);

        my $newRetention = RPS::DB::Item::McpsLicenseRetention->Create(
            mcps_license_id => $item->mcps_license_id,
            period_id       => $completedPeriods + 1,
            units_held      => $retentionsHeld,
            price           => $item->price,

            # !!! I may no longer need this column?
            #            price_level_id =>
            retention_category         => $category,
            original_statement_item_id => $item->mcps_statement_item_id,
        );
        $newRetention->save();
    }

    # Now look at carryover
    #
    if ( $item->final_net_units < 0 ) {

        # !!! I believe this should _always_ be GB...
        my $newCarryover = RPS::DB::Item::McpsLicenseCarryover->Create(
            country_code    => $item->country_code,
            mcps_license_id => $item->mcps_license_id,
            mcps_price_type => $item->mcps_price_type,
            price           => $item->price,
            units           => $item->final_net_units,
        );
        $newCarryover->save();
    }
}

sub _init {
    my ( $self, %args ) = @_;

    $self->SUPER::_init(%args);

    # This hash will be used to keep track of the McpsStatementItem references.
    #
    $self->{_items} = {};

    # Fetch the pricing scheme here.
    #
    my $modeItem = Common::DB::Item::ClientMcpsSalesPricingMode->Lookup( client_id => Common::RSApp::GetClientID() );

    if ($modeItem) {
        $self->{_pricingMode} = $modeItem->mcps_sales_pricing_mode();
    } else {

        # Default...
        #
        $self->{_pricingMode} = Common::DB::Item::ClientMcpsSalesPricingMode::kPublishedDealerPricing;
    }

    # Build the retention rate table
    #
    $self->{_retentionRate} = {
        'S' => {
            1 => 0.1,
            2 => 0.1,
            3 => 0.1,
            4 => 0.1,
        },
        'T' => {
            1 => 0.25,
            2 => 0.25,
            3 => 0.1,
            4 => 0.1,
        },
    };

    # Let's also build the export uplift table.
    #
    my $allUplifts = RPS::DB::Item::McpsExportUplift->GetAll();
    while ( my $upliftItem = $allUplifts->next() ) {
        $self->{_exportUplift}{ $upliftItem->country_code() }{ $upliftItem->product_type_id() } = $upliftItem->rate() / 100;
    }
    $self->_report( "exportUplift table",   4 );
    $self->_report( $self->{_exportUplift}, 4 );

    return $self;
}

sub _findMatchingLicenses {
    my ( $self, $sale ) = @_;
    assert($sale);

    # Should be simple enough - just grab the correct license for this product id
    # from the McpsLicense table.
    #
    #    my $license = RPS::DB::Item::McpsLicense->Lookup(payor_id => $self->_payorID(), product_id => $sale->product_id());

    # Return this as an array ref.
    #
    my @licenses;
    my $collection = RPS::DB::Item::McpsLicense->GetAll(
        "SELECT * from mcps_license" . " WHERE payor_id =" . $self->_payorID() . " AND product_id =" . $sale->product_id() );

    while ( my $license = $collection->next() ) {
        push @licenses, $license;
    }

    return \@licenses;
}

sub _getStatementItemForSale {
    my ( $self, $license, $sale ) = @_;
    assert($license);
    assert($sale);

    if ( $sale->mcps_adjustment() ) {
        return $self->_getAdjustmentStatementItemForSale( $license, $sale );
    } else {
        return $self->_getRegularStatementItemForSale( $license, $sale );
    }
}

sub _getAdjustmentStatementItemForSale {
    my ( $self, $license, $sale ) = @_;
    assert($license);
    assert($sale);

    my $countryCode = $sale->country_code();
    my ( $price, $priceType ) = $self->_getSalePrice($sale);

    return $self->_getStatementItem(
        license      => $license,
        countryCode  => $countryCode,
        price        => $price,
        priceType    => $priceType,
        isAdjustment => 1,
        comments     => $sale->comments,
        saleID       => $sale->sale_id,
    );
}

sub _getRegularStatementItemForSale {
    my ( $self, $license, $sale ) = @_;
    assert($license);
    assert($sale);

    # Deleted state comes from the product, not the license.
    #
    my $product = RPS::DB::Item::Product->Lookup( product_id => $license->product_id );

    my $countryCode = $sale->country_code();
    my ( $price, $priceType ) = $self->_getSalePrice( $sale, $product->deleted() );

    # We usually leave saleID undef.
    # But if this is a deleted product, we need to report each sale on a seperate line.
    #
    my $saleID;
    my $deletedDate;
    if ( $product->deleted() ) {
        $saleID      = $sale->sale_id;
        $deletedDate = $product->deleted_date;
    }

    return $self->_getStatementItem(
        license     => $license,
        countryCode => $countryCode,
        price       => $price,
        priceType   => $priceType,
        saleID      => $saleID,
        deletedDate => $deletedDate,
    );
}

sub _getStatementItem {
    my ( $self, %args ) = @_;

    my $license        = $args{license};
    my $countryCode    = $args{countryCode};
    my $price          = $args{price};
    my $priceType      = $args{priceType};
    my $adjustmentFlag = $args{isAdjustment};
    my $comments       = $args{comments};
    my $deletedDate    = $args{deletedDate};
    my $saleID         = $args{saleID};
    $saleID = 'REGULAR' unless $saleID;

    # We need to pad/round the price, so '5' and '5.0000' hash the same.
    $price = Common::RSMath::round( $price, 4 );

    # Have we cached this item already?
    #
    # Notice the 'REGULAR' hash key.  We are going to combine the regular statement
    # item (where there will be just 1 for a given license/country/price/type) and
    # the adjustment items (where there will be more than 1, differentiated by sale_id).
    #
    if ( !$self->{_items}{ $license->mcps_license_id }{$saleID}{$priceType}{$price}{$countryCode} ) {
        my $item = $self->_createNewStatementItem( $license, $price, $priceType, $adjustmentFlag, $comments, $countryCode, $deletedDate );

        $self->{_items}{ $license->mcps_license_id }{$saleID}{$priceType}{$price}{$countryCode} = $item;
    }

    return $self->{_items}{ $license->mcps_license_id }{$saleID}{$priceType}{$price}{$countryCode};
}

sub _createNewStatementItem {
    my ( $self, $license, $price, $priceType, $adjustmentFlag, $comments, $countryCode, $deletedDate ) = @_;
    assert($license);

    #    assert($price);
    assert($priceType);

    # Create it.  This means fetching the associated product so we can fill-in some state.
    #
    my $product = RPS::DB::Item::Product->Lookup( product_id => $license->product_id );
    die RPS::Mechanical::Process::Exception->new(
        "Unable to find product " . $license->product_id . " associated with mcps_license " . $license->mcps_license_id )
      unless $product;

    # !!! Sanity check - This should NOT be a track product...
    #
    die RPS::Mechanical::Process::Exception->new(
        "Product " . $license->product_id . " associated with mcps_license " . $license->mcps_license_id . " is a track product!" )
      if ( $product->product_type_id == RPS::DB::Item::Product::kProductTypeDigitalTrack );

    # Fetch the album.
    #
    my $album = RPS::DB::Item::Album->Lookup( album_id => $product->asset_id() )
      or die RPS::Mechanical::Process::Exception->new( "Unable to load album "
          . $product->asset_id
          . " from product "
          . $license->product_id
          . " associated with mcps_license "
          . $license->mcps_license_id );

    # And fetch the artist.
    #
    my $artist = RPS::DB::Item::Artist->Lookup( artist_id => $album->artist_id() )
      or die RPS::Mechanical::Process::Exception->new( "ERROR - Unable to load artist "
          . $album->artist_id()
          . "from album"
          . $product->asset_id
          . " from product "
          . $license->product_id
          . " associated with mcps_license "
          . $license->mcps_license_id );

    # And the label info.
    #
    my $label = RPS::DB::Item::Label->Lookup( label_id => $album->label_id() )
      or die RPS::Mechanical::Process::Exception->new( "ERROR - Unable to load label"
          . $album->label_id()
          . "from album"
          . $product->asset_id
          . " from product "
          . $license->product_id
          . " associated with mcps_license "
          . $license->mcps_license_id );

    my $item = RPS::DB::Item::McpsStatementItem->Create(
        mcps_statement_id => $self->_statementID(),
        mcps_license_id   => $license->mcps_license_id(),
        mcps_id           => $license->mcps_id(),
        album_title       => $album->title(),
        album_id          => $album->album_id(),
        catalog_number    => $album->catalog_number(),
        artist_name       => $artist->name(),
        product_code      => $product->product_code(),
        product_type_id   => $product->product_type_id(),
        dvd_category      => $license->dvd_category(),
        label_name        => $label->label_name(),
        release_date      => $product->release_date(),
        country_code      => $countryCode,
        price             => $price,
        mcps_price_type   => $priceType,
        adjustment        => $adjustmentFlag,
        comments          => $comments,
        deleted           => ( defined $deletedDate ? 1 : 0 ),
        deleted_date      => $deletedDate,
    );

    # Save the initial state - We want to make sure we have an id assigned.
    #
    $item->save();

    $self->_report( "----- created this new statement item", 4 );
    $self->_report( $item,                                   4 );

    return $item;
}

# Returns the correct price to use for a sale and product.
#
sub _getSalePrice {
    my ( $self, $sale, $deletedFlag ) = @_;
    assert($sale);

    my $price;
    my $priceType;

    my $conversionRate = $sale->conversion_rate();
    assert( $conversionRate, "Conversion rate must be > 0" );

    if ($deletedFlag) {

        # Deleted products will always report the 'retail price' found in the sale file.
        #
        $price = $sale->retail_price() * $conversionRate;

        $priceType = RPS::DB::Item::McpsStatementItem::kPriceTypeDeleted;
    } elsif ( RPS::DB::Item::Channel::kChannelClub == $sale->channel
        || RPS::DB::Item::Channel::kChannelMailOrder == $sale->channel ) {

        # 'Non-standard' sales channel.  We need a 'retail' price.
        # This will come from either the product's price table, or directly from the sales file if
        # the system is configured that way.
        #
        $priceType = RPS::DB::Item::McpsStatementItem::kPriceTypeRetail;

        if ( Common::DB::Item::ClientMcpsSalesPricingMode::kSaleDocumentPricing eq $self->{_pricingMode} ) {
            $price = $sale->retail_price() * $conversionRate;
        } elsif ( $sale->price_level ) {
            $price = RPS::Product::Price::GetRetailProductPrice( $sale->product_id, $sale->price_level );
        }
    } else {
        $priceType = RPS::DB::Item::McpsStatementItem::kPriceTypeDealer;

        if ( Common::DB::Item::ClientMcpsSalesPricingMode::kSaleDocumentPricing eq $self->{_pricingMode} ) {
            $price = $sale->wholesale_price() * $conversionRate;
        } else {
            $price = RPS::Product::Price::GetWholesaleProductPrice( $sale->product_id, $sale->price_level );
        }
    }

    # Let's blow up if we don't have a price (and it's not a free or promo sale)...
    #

    my $priceLevel = $sale->price_level || 0;
    if ( 0 == $price && !$sale->free && $priceLevel != RPS::DB::Item::PriceLevel::kPriceLevelPromo ) {

        # !!! This would be a good place for an exception class.
        #
        die RPS::Mechanical::Process::SaleException::NoPrice->new( $sale, 'Unable to determine the price for this sale' );
    }

    return ( Common::RSMath::round( $price, 4 ), $priceType );
}

sub _updateStatementItemWithSale {
    my ( $self, $statementItem, $sale ) = @_;

    # MCPS settings => Published dealer price
    my ( $isPublishedDealerPrice, $isPromoProduct ) = (0, 0);
    if ( Common::DB::Item::ClientMcpsSalesPricingMode::kPublishedDealerPricing eq $self->{_pricingMode} ) {
        $isPublishedDealerPrice = 1;

        my $oProduct = RPS::DB::Item::Product->Lookup( product_id => $sale->product_id );
        my $defaultPiceLevelID = $oProduct->default_price_level_id || 0;
        $isPromoProduct = 1 if $defaultPiceLevelID == RPS::DB::Item::PriceLevel::kPriceLevelPromo;
    }

    # !!! Should this be checking retail price?  !!!!
    # Determine which 'buckets' to update.
    #

    if (   ( $isPublishedDealerPrice && $isPromoProduct )
        || ( !$isPublishedDealerPrice && ( $sale->free || 0 == $sale->retail_price ) )
    ) {
        # This is a promo sale.
        #
        $self->_report( " sale is 'free', adding units to promo_units AND gross_units", 4 );
        $statementItem->promo_units( $statementItem->promo_units() + $sale->sales() );
        $statementItem->gross_units( $statementItem->gross_units() + $sale->sales() );
    } else {

        # A regular sort of sale line, with sales and returns.
        #
        $self->_report( " sale is 'normal', adding units to gross_units", 4 );
        $statementItem->gross_units( $statementItem->gross_units() + $sale->sales() );

        my $clientID = Common::RSApp::GetClientID();

        # If this is a 'foreign' sale, we do not allow returns...
        #
        if ( 'GB' eq $sale->country_code() ) {
            $statementItem->return_units( $statementItem->return_units() + $sale->returns() );
        } elsif ( 'IE' eq $sale->country_code() && 182 == $clientID )    # 182 = MOS
        {
            # ..unless you're Ministry of Sound, in which case MCPS says you
            # can also report on Ireland sales in addition to GB sales.  See
            # FB12504 for the details.
            $statementItem->return_units( $statementItem->return_units() + $sale->returns() );
        }
    }
}

# Re-write this whole function from scratch.
# We need to take the consolidation into account as we go.
#
sub _finalize {
    my ($self) = @_;

    $self->_report( "ITEM DUMP: ", 4 );
    $self->_report( $self->{_items} );

    # For some clients, we will consolidate statement items by
    # allowing for a 20% price variance.
    #
    if ( RPS::DB::Item::ClientOptions->Get( 'uk_mech_price_variance' ) ) {  # Feature flag (RSD-10926)
        $self->{_items} = $self->_consolidateItems( $self->{_items} );
    }

    # Note that this will return a new hash, since the prices might change.
    #
    $self->{_items} = $self->_calculateNet( $self->{_items} );

    $self->_calculateFinalNet( $self->{_items} );

    # All done messing about with the items - Save them out to the DB.
    #
    $self->_saveItems( $self->{_items} );
}

sub _consolidateItems {
    my ( $self, $inHash ) = @_;

    my %priceHash;
    my %outHash;

    # First, let's put the items in a new hash in the order that we need
    foreach my $licenseID ( keys %$inHash ) {
        foreach my $saleID ( keys %{ $inHash->{$licenseID} } ) {
            foreach my $priceType ( keys %{ $inHash->{$licenseID}{$saleID} } ) {
                foreach my $price ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType} } ) {
                    foreach my $countryCode ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType}{$price} } ) {
                        # Save the item in the new hash
                        #
                        my $item = $inHash->{$licenseID}{$saleID}{$priceType}{$price}{$countryCode};
                        $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode}{$price} = $item;
                    }
                }
            }
        }
    }

    foreach my $licenseID ( keys %priceHash ) {
        foreach my $saleID ( keys %{ $priceHash{$licenseID} } ) {
            foreach my $priceType ( keys %{ $priceHash{$licenseID}{$saleID} } ) {
                foreach my $countryCode ( keys %{ $priceHash{$licenseID}{$saleID}{$priceType} } ) {
                    my $bucketPrice;
                    my $bucketItem;
                    foreach my $price ( sort { $b <=> $a } keys %{ $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode} } ) {
                        my $item = $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode}{$price};

                        # If this is the first item in this bucket, use its price.
                        if (!$bucketPrice) {
                            $bucketPrice = $price;
                            $bucketItem = $item;
                        } else {
                            # If we already have an item in the bucket, see if this price is close enough
                            # for consolidation.

                            if ($price >= $bucketPrice * .975) {
                                # Consolidate it!
                                # Add the important stuff to the bucket item
                                # and then get rid of this one.
                                $bucketItem->gross_units( $bucketItem->gross_units + $item->gross_units );
                                $bucketItem->prior_units( $bucketItem->prior_units + $item->prior_units );
                                $bucketItem->promo_units( $bucketItem->promo_units + $item->promo_units );
                                $bucketItem->return_units( $bucketItem->return_units  + $item->return_units );
                                $bucketItem->net_units( $bucketItem->net_units + $item->net_units );
                                $bucketItem->retentions( $bucketItem->retentions + $item->retentions );
                                $bucketItem->tv_retentions( $bucketItem->tv_retentions + $item->tv_retentions );

                                # We need to delete it from the database and the hash.
                                $item->delete();
                                delete $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode}{$price};
                            } else {
                                # Make a new bucket!
                                $bucketPrice = $price;
                                $bucketItem = $item;
                            }
                        }
                    }
                }
            }
        }
    }

    # Okay, now put this back in the order that the rest of the code is expecting.
    foreach my $licenseID ( keys %priceHash ) {
        foreach my $saleID ( keys %{ $priceHash{$licenseID} } ) {
            foreach my $priceType ( keys %{ $priceHash{$licenseID}{$saleID} } ) {
                foreach my $countryCode ( keys %{ $priceHash{$licenseID}{$saleID}{$priceType} } ) {
                    foreach my $price ( keys %{ $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode} } ) {
                        # Save the item in the new hash
                        my $item = $priceHash{$licenseID}{$saleID}{$priceType}{$countryCode}{$price};
                        $outHash{$licenseID}{$saleID}{$priceType}{$price}{$countryCode} = $item;
                    }
                }
            }
        }
    }

    return \%outHash;
}

sub _saveItems {
    my ( $self, $inHash ) = @_;

    $self->_report( "_saveItems", 3 );

    # This seems like a good place to calculate the statement_level estimates.
    #
    my $netUnits;
    my $grossUnits;
    my $estimatedLiability;
    my $retentionsHeld;
    my $retentionsLiquidated;
    my $royaltyPayableUnits;

    # We'll need to read in the rate table.
    #
    my %rateTable;

    my $allRates = Common::DB::Item::McpsDefaultRoyaltyRate->GetAll();
    while ( my $rateItem = $allRates->next() ) {
        $rateTable{ $rateItem->mcps_sale_channel_id }{'AP1'} = $rateItem->ap1_rate();
        $rateTable{ $rateItem->mcps_sale_channel_id }{'A'}   = $rateItem->cat_a_rate();
        $rateTable{ $rateItem->mcps_sale_channel_id }{'B'}   = $rateItem->cat_b_rate();
        $rateTable{ $rateItem->mcps_sale_channel_id }{'C'}   = $rateItem->cat_c_rate();
        $rateTable{ $rateItem->mcps_sale_channel_id }{'AVP'} = $rateItem->cat_avp_rate();
    }

    foreach my $licenseID ( keys %$inHash ) {
        foreach my $saleID ( keys %{ $inHash->{$licenseID} } ) {
            foreach my $priceType ( keys %{ $inHash->{$licenseID}{$saleID} } ) {
                foreach my $price ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType} } ) {
                    foreach my $countryCode ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType}{$price} } ) {
                        $self->_report(
                            " looking at licenseID $licenseID saleID $saleID priceType $priceType price $price countryCode $countryCode",
                            3 );

                        my $item = $inHash->{$licenseID}{$saleID}{$priceType}{$price}{$countryCode};
                        $item->save();

                        $self->_report( $item, 4 );

                        my $category = $item->dvd_category();
                        $category = 'AP1' unless $category;

                        # This channel_id logic is a bit squirrly...
                        #
                        my $channelID;
                        $channelID = '1' if $item->mcps_price_type eq 'D';
                        $channelID = '2' if $item->mcps_price_type eq 'R';
                        $channelID = '2' if $item->mcps_price_type eq 'L';
                        $channelID = '3' if $item->mcps_price_type eq 'I';

                        my $rate = $rateTable{$channelID}{$category};

                        # !!! Might need to re-visit these calculations.
                        #
                        $netUnits   += $item->final_net_units;
                        $grossUnits += $item->gross_units;

                        # Net negative units are carried over to the next
                        # accounting period, so they should not be included
                        # in the Estimated Liability figure.
                        #
                        if ( $item->final_net_units > 0 ) {
                            $estimatedLiability +=
                              Common::RSMath::round( ( $item->final_net_units * ( ( $rate / 100 ) * $item->price ) ), 2 );
                            $royaltyPayableUnits += $item->final_net_units;
                        }

                        $retentionsHeld += $item->retentions    if ( $item->retentions > 0 );
                        $retentionsHeld += $item->tv_retentions if ( $item->tv_retentions > 0 );

                        $retentionsLiquidated += ( -1 * $item->retentions )    if ( $item->retentions < 0 );
                        $retentionsLiquidated += ( -1 * $item->tv_retentions ) if ( $item->tv_retentions < 0 );
                    }
                }
            }
        }
    }

    my $statement = $self->_getStatementDBItem();
    $statement->net_units($netUnits);
    $statement->gross_units($grossUnits);
    $statement->estimated_liability($estimatedLiability);
    $statement->retentions_held($retentionsHeld);
    $statement->retentions_liquidated($retentionsLiquidated);
    $statement->royalty_payable_units($royaltyPayableUnits);

    $statement->save();
}

sub _calculateNet {
    my ( $self, $inHash ) = @_;

    my %outHash;

    foreach my $licenseID ( keys %$inHash ) {
        my $license = RPS::DB::Item::McpsLicense->Lookup( mcps_license_id => $licenseID );
        my $product = RPS::DB::Item::Product->Lookup( product_id => $license->product_id );

        # 'regular' items will have a 'sale_id' of 'REGULAR'.
        # If that is not the sale id, then we're looking at an adjustment.
        # I'm hashing the sale_id up here, so that all the 'normal' items will
        # naturally cluster together.
        #
        foreach my $saleID ( keys %{ $inHash->{$licenseID} } ) {
            foreach my $priceType ( keys %{ $inHash->{$licenseID}{$saleID} } ) {
                foreach my $price ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType} } ) {
                    foreach my $countryCode ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType}{$price} } ) {
                        my $item = $inHash->{$licenseID}{$saleID}{$priceType}{$price}{$countryCode};

                        $self->_report( "_calculateNet - looking at item", 4 );
                        $self->_report( $item,                             4 );

                        $self->_sumItemAndTakeRetentions($item);

                        my $newPrice = $price;

                        # export allowance?
                        #
                        if (   RPS::DB::Item::McpsStatementItem::kPriceTypeDealer eq $priceType
                            && 'GB' ne $countryCode
                            && 'REGULAR' eq $saleID
                            && $item->net_units() > $self->_exportAllowance() ) {

                            # Look for the explicit rate first.
                            # If we can't find one, look for the 'all physical products' version.
                            my $rate = $self->{_exportUplift}{$countryCode}{ $product->product_type_id() };
                            if ( !$rate ) {
                                $rate = $self->{_exportUplift}{$countryCode}{RPS::DB::Item::ProductType::kAllPhysicalProducts};
                            }

                            $self->_report( "  item has triggered export allowance : rate = $rate", 4 );

                            if ( $rate && $rate != 1 ) {
                                $newPrice = Common::RSMath::round( $item->price() * $rate, 4 );    # Why 4 decimal places, anyway? !!!
                                $item->price($newPrice);
                            }
                        }

                        # Save the item in the new hash
                        #
                        $outHash{$licenseID}{$saleID}{$priceType}{$newPrice}{$countryCode} = $item;
                    }
                }
            }
        }
    }

    return \%outHash;
}

sub _calculateFinalNet {
    my ( $self, $inHash ) = @_;

    foreach my $licenseID ( keys %$inHash ) {
        my $license = RPS::DB::Item::McpsLicense->Lookup( mcps_license_id => $licenseID );
        my $product = RPS::DB::Item::Product->Lookup( product_id => $license->product_id );

        # 'regular' items will have a 'sale_id' of 'REGULAR'.
        # If that is not the sale id, then we're looking at an adjustment.
        # I'm hashing the sale_id up here, so that all the 'normal' items will
        # naturally cluster together.
        #
        foreach my $saleID ( keys %{ $inHash->{$licenseID} } ) {
            foreach my $priceType ( keys %{ $inHash->{$licenseID}{$saleID} } ) {
                foreach my $price ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType} } ) {

                    # So here's the wacky part:
                    # If these are 'regular' items,
                    # and _any_ of them have a negative net,
                    # then we want to consolidate all the units into the GB line.
                    # Otherwise, we can pretty much leave them alone.
                    #
                    my $consolidateFlag = 0;
                    my $lumpSum         = 0;
                    my $gbItem          = undef;
                    foreach my $countryCode ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType}{$price} } ) {
                        my $item = $inHash->{$licenseID}{$saleID}{$priceType}{$price}{$countryCode};

                        $self->_report( "_calculateFinalNet - looking at item", 4 );
                        $self->_report( $item,                                  4 );

                        if ( 'GB' eq $countryCode ) {
                            $gbItem = $item;
                        }
                        $item->final_net_units( $item->net_units() - $item->tv_retentions() - $item->retentions() );
                        if ( $item->final_net_units() < 0 ) {
                            $consolidateFlag = 1;
                        }
                        $lumpSum += $item->final_net_units();
                    }

                    # Looks like 'consolidation' is not actually allowed!
                    # For now, I will leave in this logic, but I'll disable it.
                    #
                    $consolidateFlag = 0;    # !!! Skipping the consolidation logic !!!

                    if ( 'REGULAR' eq $saleID && $consolidateFlag ) {
                        $self->_report(
"_calculateFinalNet - Consolidating net units of $lumpSum - license id: $licenseID  priceType: $priceType  price: $price ",
                            4
                        );

                        # This seems unlikely, but we might not _have_ a GB item.  In this case, then, we'd need
                        # to forge one.
                        #
                        if ( !defined $gbItem ) {
                            $self->_report( "_calculateFinalNet !!! Making up a GB item", 4 );

                            $gbItem = $self->_createNewStatementItem( $license, $price, $priceType, 0, '', 'GB', undef );
                            $inHash->{$licenseID}{'REGULAR'}{$priceType}{$price}{'GB'} = $gbItem;
                        }

                        foreach my $countryCode ( keys %{ $inHash->{$licenseID}{$saleID}{$priceType}{$price} } ) {
                            my $item = $inHash->{$licenseID}{$saleID}{$priceType}{$price}{$countryCode};
                            if ( 'GB' eq $countryCode ) {
                                $item->final_net_units($lumpSum);
                            } else {
                                $item->final_net_units(0);
                            }
                        }
                    }
                }
            }
        }
    }
}

sub _sumItemAndTakeRetentions {
    my ( $self, $item ) = @_;

    my $licenseID = $item->mcps_license_id();
    my $license = RPS::DB::Item::McpsLicense->Lookup( mcps_license_id => $licenseID );

    my $completedPeriods = _periodsCompletedForLicense($license);

    # calculate the 'net' units.
    #
    $item->net_units( $item->gross_units + $item->prior_units - $item->promo_units - $item->return_units );

    # take retentions if sales > returns, and we retain on this item.
    # !!! Retentions come off of _net_ numbers.
    # !!! Only take retentions if the net_units > 10, not 0
    #
    if (   'GB' eq $item->country_code()
        && $completedPeriods < 4
        && $item->mcps_price_type eq RPS::DB::Item::McpsStatementItem::kPriceTypeDealer
        && $item->net_units() >= 10 ) {

        # TV or Regular?
        # !!! Don't we have a table someplace that holds the retention schedule?
        # !!! For now I will build one in the constructor, which will at least make that
        # !!! abstract here.
        #
        my $period = $completedPeriods + 1;
        if ( RPS::DB::Item::McpsLicense::kRetentionCategoryTVAdvertised eq $license->retention_category() ) {
            my $rate = $self->{_retentionRate}{ RPS::DB::Item::McpsLicense::kRetentionCategoryTVAdvertised() }{$period};
            $item->tv_retentions( Common::RSMath::round( ( $item->net_units() * $rate ), 0 ) );

            $self->_report( "_sumItemAndTakeRetentions:  taking TV Advertised retentions, rate $rate", 4 );
        } else {
            my $rate = $self->{_retentionRate}{ RPS::DB::Item::McpsLicense::kRetentionCategoryStandard() }{$period};
            $item->retentions( Common::RSMath::round( ( $item->net_units() * $rate ), 0 ) );

            $self->_report( "_sumItemAndTakeRetentions:  taking regular retentions, rate $rate", 4 );
        }
    } else {
        $self->_report( "_sumItemsAndTakeRetentions: NOT TAKING RETENTIONS", 4 );
    }

    # !!! Shouldn't we be dealing with liquidations here?
    # !!! Well, we might have licenses that had no sales, but are in their final period and
    # !!! need to be liquidated.
    #
    # !!! So, in theory, by the time we get here, if retentions were released, there should be a
    # _negative_ value in the 'retentions' column.
    #
    $self->_report( "_sumItemAndTakeRetentions:  exiting", 4 );
    $self->_report( $item,                                 4 );

}

sub _getStatementItemForRetention {
    my ( $self, $retention ) = @_;

    my $licenseID = $retention->mcps_license_id;
    my $license = RPS::DB::Item::McpsLicense->Lookup( mcps_license_id => $licenseID );

    my $priceType = RPS::DB::Item::McpsStatementItem::kPriceTypeDealer;

    # If this is a 'historical' reserve (entered in via the UI), then we won't actually
    # _have_ the price in the table.  And we'll therefore need to use the wholesale price.
    # Note that on commit, we'll need to fill-in the price : We want to make sure we keep
    # the price we originally retained.
    my $price = $retention->price();
    if ( 0 == $price ) {
        $price = RPS::Product::Price::GetWholesaleProductPrice( $license->product_id, $retention->price_level_id );
    }

    my $countryCode = 'GB';

    return $self->_getStatementItem(
        license     => $license,
        countryCode => $countryCode,
        price       => $price,

        #        priceType => $retention->mcps_sales_pricing_mode(),
        priceType => $priceType,
    );

}

sub _getStatementItemForLicenseCarryover {
    my ( $self, $license, $carryover ) = @_;

    return $self->_getStatementItem(
        license     => $license,
        countryCode => $carryover->country_code,
        price       => $carryover->price,
        priceType   => $carryover->mcps_price_type,
    );

}

sub _processLicenseReserves {
    my ( $self, $license ) = @_;

    my $completedPeriods = _periodsCompletedForLicense($license);

    if ( 4 == $completedPeriods ) {
        my $collection = RPS::DB::Item::McpsLicenseRetention->GetByMcpsLicenseID( $license->mcps_license_id );
        while ( my $retention = $collection->next() ) {
            $self->_liquidateRetention($retention);
        }
    }
}

sub _processLicenseCarryover {
    my ( $self, $license ) = @_;

    # See whether we had negative units held over from a previous run.
    #
    my $carryovers = RPS::DB::Item::McpsLicenseCarryover->GetAllForLicense( $license->mcps_license_id );
    while ( my $carryover = $carryovers->next() ) {
        my $item = $self->_getStatementItemForLicenseCarryover( $license, $carryover );
        $item->prior_units( $item->prior_units() + $carryover->units() );
    }
}

sub _periodsCompletedForLicense {
    my ($license) = @_;

    my $completedPeriods = RPS::DB::Item::McpsLicenseRun->NumCommittedRuns( $license->mcps_license_id );
    $completedPeriods += $license->initial_periods_completed();
    return $completedPeriods;
}

sub _getProcessedSales {
    my ($self) = @_;

    die "ERROR - You must overload _getProcessedSales";
}

sub _getUnprocessedSales {
    my ( $self, $sales ) = @_;

    # Fetch the sales that have already been processed; we need to exclude
    # these from further processing
    #
    my $col = $self->_getProcessedSales();
    my %processed;
    while ( $col->hasNext() ) {
        my $runMapItem = $col->next();
        $processed{ $runMapItem->sale_id } = 1;
    }

    # Build the set of unprocessed sales
    #
    my @unprocessedSales;
    foreach my $id (@$sales) {
        next if ( exists $processed{$id} );
        push @unprocessedSales, $id;
    }

    return \@unprocessedSales;
}

1;
