#---------------------------------------------------------------
# ____                   _ _         ____  _
#|  _ \ ___  _   _  __ _| | |_ _   _/ ___|| |__   __ _ _ __ ___
#| |_) / _ \| | | |/ _` | | __| | | \___ \| '_ \ / _` | '__/ _ \
#|  _ < (_) | |_| | (_| | | |_| |_| |___) | | | | (_| | | |  __/
#|_| \_\___/ \__, |\__,_|_|\__|\__, |____/|_| |_|\__,_|_|  \___|
#            |___/             |___/
#
# Copyright (C) 2012 RoyaltyShare, Inc.   All Rights Reserved
#---------------------------------------------------------------

package RPS::ArtistRoyalty::Fast::Script::MapSales;

use strict;
use Data::Dumper;
use File::Path;
use POSIX ":sys_wait_h";

use lib '/app/tools/common/lib';
use lib '/app/tools/rps/lib';
use lib '/app/tools/raptor/lib';

use DB_File;

use Common::Util;
use Common::Log;
use Common::Parser;
use Common::WriteXML;
use Common::RSMath;

use RPS::DB::Item::Label;
use RPS::DB::Item::Artist;
use RPS::DB::Item::Album;
use RPS::DB::Item::Product;
use RPS::DB::Item::Song;
use RPS::DB::Item::Master;
use RPS::DB::Item::Track;
use RPS::DB::Item::ProductTrack;
use RPS::DB::Item::Product;
use RPS::DB::Item::Price;
use RPS::DB::Item::ProductPrice;
use RPS::DB::Item::Channel;
use RPS::DB::Item::PriceLevel;
use RPS::DB::Item::ContractRateType;
use RPS::DB::Item::SaleProductType;
use RPS::DB::Item::Format;
use RPS::DB::Item::IncomeSource;
use RPS::DB::Item::ContractRateType;

use RPS::ArtistRoyalty::Fast::Static::Sales;
use RPS::ArtistRoyalty::Fast::Static::ProductTerms;
use RPS::ArtistRoyalty::Fast::Static::ProductPrices;
use RPS::ArtistRoyalty::Fast::Static::PaidSales;
use RPS::ArtistRoyalty::Fast::Static::IncomeSources;
use RPS::ArtistRoyalty::Fast::Static::RegionCountries;
use RPS::ArtistRoyalty::Fast::Static::Terms;
use RPS::ArtistRoyalty::Fast::Static::ProductTrack;
use RPS::ArtistRoyalty::Fast::Static::ContractTracks;
use RPS::ArtistRoyalty::Fast::Static::OtherPayorsProducts;
use RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts;

use RPS::ArtistRoyalty::Fast::Mapped::Data;
use RPS::ArtistRoyalty::Fast::Mapped::MissedSales;

use RPS::ArtistRoyalty::Fast::TermType;

use base 'Common::Script';

sub _options {
    {
        client_id => {
            short       => 'c',
            required    => 1,
            description => 'Limit to this client id',
            parameter   => 'i'
        },
        payor_id => {
            short       => 'y',
            required    => 1,
            description => 'Limit to this payor id',
            parameter   => 'i'
        },
        data_path => {
            short       => 'd',
            required    => 1,
            description => 'path to directory containing static data',
            parameter   => 's'
        },
        output_path => {
            short       => 'o',
            required    => 1,
            description => 'path to directory to write output files',
            parameter   => 's'
        },
        process_count => {
            short       => 'p',
            required    => 0,
            description => 'number of processes to use. defaults to 1',
            parameter   => 'i',
        },
        index_range => {
            short       => 'i',
            required    => 0,
            description => 'First,Last data indexes to process.  Ex: -s0,10000',
            parameter   => 's',
        },
    };
}

my $gChildCount;

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

    Log->warn("BEGIN");

    my $payorID = $self->param('payor_id');

    # JPK - I'm considering a different model, where I just fork a couple of child processes
    #       to do the work.
    #
    my ( $firstSale, $lastSale ) = $self->_parseFirstLastSaleIndex( $self->param('index_range') );

    my $dataPath = $self->param('data_path');

    my $outputDirectory = $self->param('output_path');

    # JPK - Should it be the responsibility of this process to create the directory if
    # it doesn't already exist?   It's a bit of a stretch, but then again it's convenient...
    #
    if ( !-d $outputDirectory ) {

        # !!! Might want to make this behavior optional...
        #
        mkpath($outputDirectory);
    }

    # Fetch an array reference to all the sales data.
    #
    # !!! Not sure this makes any sense, really, to pass the indexes to that interface.
    # !!! If we're getting a tied array reference back, it might be better to
    # !!! just constrain the loop.
    #
    # !!! I'm thinking of loading all the static data, then getting the sales,
    # !!! then calculating the offsets, and then forking.
    Log->info("Getting Sales");
    my $sales = RPS::ArtistRoyalty::Fast::Static::Sales->GetSales( $dataPath, $firstSale, $lastSale );

    # The term data is stored as a hash of serialized array refs.
    # So we'll need to eval each record.
    # JPK - Not particularly abstract, true, but I don't want to waste time instantiating
    # some sort of wrapper object for each product.
    #
    Log->info("Getting ProductTerms");
    my $productTermData = RPS::ArtistRoyalty::Fast::Static::ProductTerms->GetProductTermData($dataPath);

    Log->info("Getting ProductPrices");
    my $productPriceData = RPS::ArtistRoyalty::Fast::Static::ProductPrices->GetProductPriceData($dataPath);

    # We'll map sale_id to an array of term_id,track_id pairs, using get_dup.
    #
    # It's a little ugly with the get_dup, but there will be a TON of these, and
    # it's not going to be worth it to suffer the overhead of an eval.
    # We get away with that for the productTermData, but that's because the sales
    # are sorted by product_id, so we only have to do that once for each product.
    # And there are fewer products...
    #
    Log->info("Getting PaidSales");
    my $paidSalesData = RPS::ArtistRoyalty::Fast::Static::PaidSales->GetPaidSalesData($dataPath);

    # We'll need all the contract term data.
    # I think this data is small enough to read the whole thing into
    # hash of array refs.
    #
    Log->info("Getting ContractTermData");
    my $contractTermData = RPS::ArtistRoyalty::Fast::Static::Terms->GetContractTermData($dataPath);

    # These maps are also small, so we'll also slurp these up into
    # hashed array refs.
    #
    Log->info("Getting Regions");
    my $regionCountryMap = RPS::ArtistRoyalty::Fast::Static::RegionCountries->GetRegionIDToCountryCodeData($dataPath);

    Log->info("Getting IncomeSources");
    my $sourceIDMap = RPS::ArtistRoyalty::Fast::Static::IncomeSources->GetIncomeSourceIDToContractTermSourceIDData($dataPath);

    Log->info("Getting ProductTracks");
    my $hProductTrackMap = RPS::ArtistRoyalty::Fast::Static::ProductTrack->GetProductTrackData();

    Log->info("Getting ContractTracks");
    my $hContractTrackMap = RPS::ArtistRoyalty::Fast::Static::ContractTracks->GetContractTracksData();

    Log->info("Getting OtherPayorsProducts");
    my $hOtherPayorsProductsMap = RPS::ArtistRoyalty::Fast::Static::OtherPayorsProducts->GetOtherPayorsProducts($payorID);

    Log->info("Getting MissedSaleProductData");
    my $hMissedSaleProductData = RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts->GetMissedSaleProductData();

    Log->info("Finished caching data");

    # JPK - At this point we have handles, file descriptors, etc, from everybody.
    #
    # !!! If I fork, but then have the parent do work, it might confuse any wrapper scripts, because
    # !!! the parent could exit before the children are done.
    # !!! So, instead, we'll fork off children to do all the work, and this process will wait until
    # !!! they all exit.
    #
    my $numSales = scalar(@$sales);
    Log->warn("Number of sales: $numSales");

    my $childCount = $self->param('process_count');
    $childCount = 1 unless $childCount;

    my $salesPerProcess = int( $numSales / $childCount );
    Log->warn("sales per process: $salesPerProcess");

    $firstSale = 0;
    $lastSale  = $firstSale + $salesPerProcess;

    #
    # !!! These are nice to have, I guess, but we'll probably use a simpler global semaphore.
    #
    my @childPIDs;
    $gChildCount = 0;

    for ( my $x = 0 ; $x < $childCount ; $x++ ) {
        my $pid = fork();
        if ( !$pid ) {

            # !!! There may be a bit of a race condition here.  I want to spawn all the children, then set up the parent's signal handlers.
            # !!! We might need to wait a bit before proceeding in the child to allow all that to get set up.
            #
            return $self->_doTheMapping(
                $firstSale,        $lastSale,      $outputDirectory,  $sales,            $productTermData,
                $productPriceData, $paidSalesData, $contractTermData, $regionCountryMap, $sourceIDMap,
                $hProductTrackMap, $hContractTrackMap,
                $hOtherPayorsProductsMap, $hMissedSaleProductData
            );
        } else {
            $gChildCount++;

            $firstSale = $lastSale;

            # Include all remaining sales if the next child is the last one.
            #
            if ( $x == ( $childCount - 2 ) ) {
                $lastSale = $numSales;
            } else {
                $lastSale = $firstSale + $salesPerProcess;
            }
        }
    }

    $SIG{CHLD} = \&REAPER;
    $SIG{INT}  = 'IGNORE';

    while ($gChildCount) {
        sleep(2);
    }

    Log->warn("All children finished, exiting");
}

sub CTRL_C {
    Log->warn("caught a ctrl-c, exiting");
    exit(1);
}

sub REAPER {
    my $deadPid;
    while ( ( $deadPid = waitpid( -1, &WNOHANG ) ) > 0 ) {
        $gChildCount--;
    }
    $SIG{CHLD} = \&REAPER;
}

sub _doTheMapping {
    my (
        $self,             $firstSale,     $lastSale,         $outputDirectory,  $sales, $productTermData,
        $productPriceData, $paidSalesData, $contractTermData, $regionCountryMap, $sourceIDMap,
        $hProductTrackMap, $hContractTrackMap,
        $hOtherPayorsProductsMap, $hMissedSaleProductData
    ) = @_;

    # This is in the child process.
    # Install a signal handler to catch ctrl-c
    $SIG{INT} = \&CTRL_C;

    my $incomeItemFD  = $self->_openIncomeItemFileDescriptor($outputDirectory);
    my $missedSalesFD = $self->_openMissedSalesFileDescriptor($outputDirectory);

    my $lastTermsProductID;
    my $saleTermsAndTracks;
    my $saleProductPrices;
    my $missedSaleProductData;

    Log->warn("mapping sales $firstSale up to $lastSale");

    #    foreach my $saleLine (@$sales)
    for ( my $i = $firstSale ; $i < $lastSale ; $i++ ) {
        my $saleLine = $sales->[$i];

        Log->info( "\n\nSALE LINE:", $saleLine );

        # !!! Let's just implement this in-line for now.
        # !!! Save the overhead of function calls, but at the price
        # !!! of a big-ass function and low-abstraction.
        my @saleArray = split( "\t", $saleLine );
        my $sale = \@saleArray;

        # I think it's worth it to dereference fields for readability.
        #
        my $productID = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kProductID];
        my $saleID    = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kSaleID];

        # Fetch all the term_id,track_id pairs associated with the sale's product_id.
        #
        if ( $productID != $lastTermsProductID ) {
            $saleTermsAndTracks = eval( $productTermData->{$productID} );
            $saleProductPrices  = eval( $productPriceData->{$productID} );
            $missedSaleProductData = $hMissedSaleProductData->{$productID};
            $lastTermsProductID = $productID;
        }

        if (   !$saleTermsAndTracks
            || !scalar @$saleTermsAndTracks ) {

            # If this product is covered by an active contract for another payor,
            # do not add it to the missed sale log.  Just skip it.
            if ($hOtherPayorsProductsMap->{$productID}) {
                next;
            } else {
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoContracts,
                    $missedSaleProductData
                );
                next;
            }
        }


        my %paidMap;
        my @contractTracks = $paidSalesData->get_dup($saleID);
        foreach my $contractTrack (@contractTracks) {
            Log->info( " Contract Track for Sale: $saleID", $contractTrack );

            # !!! Look at pack/unpack for this stuff...
            #
            my @bits = split( ',', $contractTrack );
            $paidMap{ $bits[0] }{ $bits[1] } = 1;
        }

        Log->info( "paidMap:", \%paidMap );

        # Now we can start digging a bit deeper into the sale, and see which of the remaining terms actually fit it.
        #
        # !!! In old code, we actually ignore the sale.product_type, and instead use a mapping function based
        # !!! on product.product_type_id.
        # !!! It would be swell if we could get that to work in the original query.
        # !!! The problem seems to be that joining the product table, even though product_id is indexed, takes
        # !!! a LOT LONGER, and gets worse as the sale table grows.   So that's not a good solution.
        #
        #
        my $incomeSourceID = $self->_determineIncomeSourceID( $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kProductType],
            $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kFormatType] );
        if ( !$incomeSourceID ) {
            $self->logMissedSale(
                $missedSalesFD,
                $sale,
                RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoIncomeSource,
                $missedSaleProductData
            );
            next;
        }


        my $saleDate       = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kDateEnd];
        my $retailPrice    = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kRetailPrice];
        my $wholesalePrice = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kWholesalePrice];
        my $grossRevenue   = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kGrossRevenue];

        my $netRevenue;

        my $productType = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kProductType];
        if ($productType eq 'A' || $productType eq 'T') {
            # digital sales
            my $units = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kSales] - $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kReturns];
            $netRevenue = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kPrice()] * $units;
        } else {
            # physical sales
            $netRevenue = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kTotalRevenue()];
        }

        # The terms are already sorted by contract_id and priority.
        #
        my $lastContractID;
        my $lastTrackID;
        my $defaultTerm;
        my $matchingTerm;
        my $missingRetail;
        my $missingWholesale;
        my $missingGrossRevenue;

        my $albumStatus = $missedSaleProductData->[RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts::kAlbumStatus];
        my $productStatus = $missedSaleProductData->[RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts::kProductStatus];

        Log->info( "saleTermsAndTracks:", $saleTermsAndTracks );

        foreach my $termTrackData (@$saleTermsAndTracks) {
            my $contractID = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractID];
            my $priority   = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kPriority];
            my $termID     = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractTermID];
            my $trackID    = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTrackID];
            my $rateTypeID = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kRateTypeID];

            # Compare the sale date with the contract's term_start and term_end, if any.
            # Skip terms that don't fit the date.
            #
            my $termStart = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTermStart];
            my $termEnd   = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTermEnd];

            if (   ( $termStart && $termStart gt $saleDate )
                || ( $termEnd && $termEnd lt $saleDate ) ) {
                Log->info("skipping term: sale date out of range");

                # Note that this is not included in the unallocated sales report.
                next;
            }

            if ( $rateTypeID == RPS::DB::Item::ContractRateType::kRateTypePercentDocumentRetail
                && ( !$retailPrice || $retailPrice == 0 ) ) {
                Log->info("skipping term: no retail price for sale");

                # Setting a flag so that we can add this to the missed sale log
                # if we can't find any other terms for the sale.
                $missingRetail = 1;

                next;
            }

            if ( $rateTypeID == RPS::DB::Item::ContractRateType::kRateTypePercentDocumentWholesale
                && ( !$wholesalePrice || $wholesalePrice == 0 ) ) {
                Log->info("skipping term: no wholesale price for sale");

                # Setting a flag so that we can add this to the missed sale log
                # if we can't find any other terms for the sale.
                $missingWholesale = 1;

                next;
            }

            if ( $rateTypeID == RPS::DB::Item::ContractRateType::kRateTypePercentGrossRevenue
                && ( !$grossRevenue || $grossRevenue == 0 ) ) {
                Log->info("skipping term: no gross revenue for sale");
                # Setting a flag so that we can add this to the missed sale log
                # if we can't find any other terms for the sale.
                $missingGrossRevenue = 1;
                $lastContractID = $contractID;
                $lastTrackID = $trackID;

                next;
            }

            # Can't just use contractID.  We also need to use track_id.
            # The same contract can land on the same album sale multiple times, and must be paid
            # out for each instance.
            #
            if ( $contractID != $lastContractID || $trackID != $lastTrackID ) {
                if ($lastContractID) {

                    # We've seen all possible terms for this contract, so we have a winner.
                    #
                    if ( $albumStatus == 0 ) { # 1 = active, 0 = inactive
                        Log->info("Album is inactive");
                        $self->logMissedSale(
                            $missedSalesFD,
                            $sale,
                            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kAlbumInactive,
                            $missedSaleProductData,
                            $lastContractID
                        );
                    } elsif ( $productStatus == 2 ) { # 1 = active, 2 = inactive
                        Log->info("Product is inactive");
                        $self->logMissedSale(
                            $missedSalesFD,
                            $sale,
                            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kProductInactive,
                            $missedSaleProductData,
                            $lastContractID
                        );
                    } else {
                        if ( !$matchingTerm ) {
                            $matchingTerm = $defaultTerm;
                        }
                        if ($matchingTerm) {
                            my $termID  = $matchingTerm->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractTermID];
                            my $trackID = $matchingTerm->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTrackID];
                            if ( !$paidMap{$lastContractID}{$trackID} ) {
                                $self->_outputLine(
                                    $incomeItemFD,
                                    $missedSalesFD,
                                    $incomeSourceID,
                                    $sale,
                                    $matchingTerm,
                                    $contractTermData,
                                    $saleProductPrices,
                                    $hProductTrackMap,
                                    $hContractTrackMap,
                                    $missedSaleProductData );
                            }
                        } elsif ( !$paidMap{$lastContractID}{$trackID} ) {
                            $self->logMissedSale(
                                $missedSalesFD,
                                $sale,
                                RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoDefaultTerm,
                                $missedSaleProductData,
                                $lastContractID
                            );
                        }
                    }
                }
                $defaultTerm    = undef;
                $matchingTerm   = undef;
                $lastContractID = $contractID;
                $lastTrackID    = $trackID;
            }

            # !!! If we are iterating over the terms in priority order, then this logic will cause the 'lower' priority term to win.
            # !!! In theory, if the terms are properly sorted, we'll see the default term first.
            # !!! Then, if we _find_ a matching term, aren't we done?
            #
            my $term = $contractTermData->{$termID};
            if ( 0 == $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kPriority] ) {
                $defaultTerm = $termTrackData;
                next;
            }

            # If the sale doesn't have a price_level, we use the product's default price level id
            #
            my $priceLevel = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kPriceLevel() ];
            if ( 0 == $priceLevel ) {
                $priceLevel = $termTrackData->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kDefaultPriceLevelID];
            }

            my $term_region      = $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kRegionID() ];
            my $sale_region      = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kCountryCode() ];
            my $term_channel     = $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kChannelID() ];
            my $sale_channel     = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kChannel() ];
            my $term_price_level = $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kPriceLevelID() ];
            my $term_contract_term_source = $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kContractTermSourceID() ];

            if ( !$matchingTerm
                && ( $term_region == 0 || $regionCountryMap->{ $term_region }{ $sale_region } )
                && ( $term_channel == 0 || $term_channel == $sale_channel )
                && ( $term_price_level == 0 || $term_price_level == $priceLevel )
                && ( $sourceIDMap->{ $incomeSourceID }{ $term_contract_term_source} ) ) {
                    $matchingTerm = $termTrackData;
                    #
                    # We've got a term. It wins.
                }
        }

        # Handle the last term outside of the loop.
        #
        if ($lastContractID) {

            if ( $albumStatus == 0 ) { # 1 = active, 0 = inactive
                Log->info("Album is inactive");
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kAlbumInactive,
                    $missedSaleProductData,
                    $lastContractID
                );
            } elsif ( $productStatus == 2 ) { # 1 = active, 2 = inactive
                Log->info("Product is inactive");
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kProductInactive,
                    $missedSaleProductData,
                    $lastContractID
                );
            } else {
                # We've seen all possible terms for this contract, so we have a winner.
                #
                if ( !$matchingTerm ) {
                    $matchingTerm = $defaultTerm;
                }
                if ($matchingTerm) {

                    # JPK - Ok, NOW we can see whether we've already paid out on this term.
                    #
                    my $termID  = $matchingTerm->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractTermID];
                    my $trackID = $matchingTerm->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTrackID];

                    # We should prevent default contract term from processing SYNC product format. RSD-6709
                    my $term_data = $self->_getTermObj( $matchingTerm, $contractTermData );
                    my $contract_term_source_id = $term_data->[ RPS::ArtistRoyalty::Fast::Static::Terms::kContractTermSourceID];
                    my $sale_format_type = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kFormatType];

                    if (! defined($contract_term_source_id) && ($sale_format_type eq RPS::File::Sale::FORMAT_SYNC)) {
                        Log->info("SYNC should not be included to default term!");
                        $self->logMissedSale(
                            $missedSalesFD,
                            $sale,
                            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoTermSync,
                            $missedSaleProductData
                        );
                    } elsif ( !$paidMap{$lastContractID}{$trackID} ) {
                        $self->_outputLine(
                            $incomeItemFD,
                            $missedSalesFD,
                            $incomeSourceID,
                            $sale,
                            $matchingTerm,
                            $contractTermData,
                            $saleProductPrices,
                            $hProductTrackMap,
                            $hContractTrackMap,
                            $missedSaleProductData
                        );
                    }
                } elsif ( !$paidMap{$lastContractID}{$lastTrackID} ) {
                    if ( $missingGrossRevenue == 1 ) {
                        $self->logMissedSale(
                            $missedSalesFD,
                            $sale,
                            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoGrossRevenue,
                            $missedSaleProductData,
                            $lastContractID
                        );
                    } else {
                        $self->logMissedSale(
                            $missedSalesFD,
                            $sale,
                            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoDefaultTerm,
                            $missedSaleProductData,
                            $lastContractID
                        );
                    }
                }
            }

        } else {
            # If we didn't find any other matching terms, we need to see if any
            # terms were rejected because of missing price data in the sale.
            # If so, add it to the missed sale log.
            if ( $missingRetail == 1 ) {
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoRetailPrice,
                    $missedSaleProductData,
                    $lastContractID
                );
            } elsif ( $missingWholesale == 1 ) {
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoWholesalePrice,
                    $missedSaleProductData,
                    $lastContractID
                );
            } elsif ( $missingGrossRevenue == 1 && !$paidMap{$lastContractID}{$lastTrackID} ) {
                $self->logMissedSale(
                    $missedSalesFD,
                    $sale,
                    RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoGrossRevenue,
                    $missedSaleProductData,
                    $lastContractID
                );
            }
        }
    }
}

sub _openIncomeItemFileDescriptor {
    my ( $self, $outputDirectory ) = @_;

    open OUTPUT, "> $outputDirectory/" . RPS::ArtistRoyalty::Fast::Mapped::Data->FileName() . "_$$.unsorted" or die "ERROR: $!";

    return *OUTPUT;
}

sub _openMissedSalesFileDescriptor {
    my ( $self, $outputDirectory ) = @_;

    open MISSED, "> $outputDirectory/" . RPS::ArtistRoyalty::Fast::Mapped::MissedSales->FileName() . "_$$.unsorted" or die "ERROR: $!";

    return *MISSED;
}

sub _parseFirstLastSaleIndex {
    my ( $self, $rangeString ) = @_;
    return unless $rangeString;

    my @bits = split( ',', $rangeString );
    if ( 2 != scalar @bits || ( $bits[1] <= $bits[0] ) ) {
        $self->usage('ERROR: sale_range is invalid.');
    }
    return ( $bits[0], $bits[1] );
}

sub logMissedSale {
    my ( $self, $outFD, $sale, $reason, $productData, $artistContractID ) = @_;

    $artistContractID = 0 unless $artistContractID;

    my $description = '';

    my $productType = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kProductType];
    my $productID   = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kProductID];

    my $formatType = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kFormatType];
    # Ignore format type of zero
    if ($formatType eq '0') {
        $formatType = '';
    } else {
        # We want to consolidate the PI format types
        # into the two top level ones (EPH and N-EPH).
        if ( '!' eq $formatType
            || '@' eq $formatType
            || '#' eq $formatType
            || '$' eq $formatType
            || '%' eq $formatType
            || '[' eq $formatType
            || ']' eq $formatType
            || ':' eq $formatType ) {
            $formatType = RPS::File::Sale::FORMAT_EPHEMERAL;
        } elsif ( '&' eq $formatType
            || '*' eq $formatType
            || '(' eq $formatType
            || ')' eq $formatType
            || '_' eq $formatType
            || '{' eq $formatType
            || '}' eq $formatType
            || ';' eq $formatType ) {
            $formatType = RPS::File::Sale::FORMAT_NON_EPHEMERAL;
        }
    }

    # Add some details if this was a missing income source situation.
    # JPK - Expand this a bit.
    #
    if ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoIncomeSource == $reason ) {
        $description =
            'No income source id matches product type '
          . $self->_idToSaleProductType( $productType )
          . ', format type '
          . $self->_idToFormatType( $formatType );
    } elsif ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoDefaultTerm == $reason ) {
        my $incomeSourceID = $self->_determineIncomeSourceID( $productType, $formatType );
        my ( $incomeSource, $formatFlag ) = $self->_idToIncomeSourceAndPhysicalFlag($incomeSourceID);
        $description =
            'No term found for country code '
          . $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kCountryCode() ]
          . ', income source '
          . $incomeSource;
        if ( 1 == $formatFlag ) {
            $description .=
                ', price level '
              . $self->_idToPriceLevel( $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kPriceLevel() ] )
              . ', channel '
              . $self->_idToChannel( $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kChannel() ] );
        }
    } elsif ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoPrice == $reason ) {
        $description = 'No product price entry for price level '
          . $self->_idToPriceLevel( $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kPriceLevel() ] );
    } elsif ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoTermSync == $reason ) {
        $description = "No term found for country code '" .
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kCountryCode() ] . "', income source 'Orchard Synchronization Revenue'";
    } elsif ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoTrackIncluded == $reason ) {
        $description = "Track status set to Not Included on product";
    } elsif ( RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoGrossRevenue == $reason ) {
        my $incomeSourceID = $self->_determineIncomeSourceID( $productType, $formatType );
        my ( $incomeSource, $formatFlag ) = $self->_idToIncomeSourceAndPhysicalFlag($incomeSourceID);
        $description =
            'No term found for Net Revenue, country code '
          . $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kCountryCode() ]
          . ', income source '
          . $incomeSource;
        if ( 1 == $formatFlag ) {
            $description .=
                ', price level '
              . $self->_idToPriceLevel( $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kPriceLevel() ] )
              . ', channel '
              . $self->_idToChannel( $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kChannel() ] );
        }
    }

    my $formatString =
      '%12d'             # album id
      . "\t" . '%12d'    # track id
      . "\t" . '%12d'    # product type id
      . "\t" . '%s'      # format type
      . "\t" . '%12d'    # artist contract id
      . "\t" . '%12d'    # reason code
      . "\t" . '%s'      # description
      . "\t" . '%12d'    # units
      . "\t" . '%20.08f' # net revenue
      . "\t" . '%20.08f' # gross revenue
      . "\n";

    my $units = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kSales] - $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kReturns];

    my $grossRevenue = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kGrossRevenue()];

    my $netRevenue;

    if ($productType eq 'A' || $productType eq 'T') {
        # digital sales
        $netRevenue = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kPrice()] * $units;
    } else {
        # physical sales
        $netRevenue = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kTotalRevenue()];
    }

    my $conversionRate = $sale->[RPS::ArtistRoyalty::Fast::Static::Sales::kConversionRate()];
    if ($conversionRate) {
        $netRevenue *= $conversionRate;
    }

    my $albumID = $productData->[RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts::kAlbumID];
    my $trackID = $productData->[RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts::kTrackID];
    my $productTypeID = $productData->[RPS::ArtistRoyalty::Fast::Static::MissedSaleProducts::kProductTypeID];

    print $outFD sprintf(
        $formatString,

        $albumID,
        $trackID,
        $productTypeID,
        $formatType,
        $artistContractID,
        $reason,
        $description,
        $units,
        $netRevenue,
        $grossRevenue
    );
}

sub _outputLine {
    my ( $self, $outFD, $missedSalesFD, $incomeSourceID, $sale, $productTerm,
        $termData, $productPrices, $hProductTrackMap, $hContractTrackMap,
        $missedSaleProductData ) = @_;

    my $term = $self->_getTermObj( $productTerm, $termData );
    Log->info( " USING TERM:",    $term );
    Log->info( " productPrices:", $productPrices );

    # JPK - If there isn't an actual product price, we need to know that so we can skip this sale.
    # (and log it at some point).
    #
    my ( $priceLevel, $price, $rate, $prorate ) = $term->getRateData( $sale, $productTerm, $productPrices, $incomeSourceID );

    # !!! Rate needs to be rounded to 4 decimal places so lines will aggregate correctly with reserves.
    #
    $rate = Common::RSMath::round( $rate, 4 );

    Log->info( " priceLevel=", $priceLevel, ' price=', $price, ' rate=', $rate );

    if ( !defined $price ) {
        Log->info( "no price returned to sale "
              . $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kSaleID() ]
              . ", term "
              . $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kArtistContractTermID() ] );
        $self->logMissedSale(
            $missedSalesFD,
            $sale,
            RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoPrice,
            $missedSaleProductData,
            $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractID() ]
        );
        return;
    }

    # RSD-7170
    my $artistContractID = $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractID() ];
    my $saleTrackID      = $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTrackID() ];
    my $contractTrack    = "$artistContractID|$saleTrackID";

    # If a track has assigned contract
    if ( exists $hContractTrackMap->{$contractTrack} ) {
        my $saleProductID    = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kProductID() ];
        my $saleProductType  = $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kProductType() ];
        my $saleProductTrack = "$saleProductID|$saleTrackID";

        # If the product type matches the album-level product and is NOT INCLUDED,
        # exclude this sale from a statement
        my $reAlbumLevelProduct = qr/^(?:1|2|3|4|7|9|A|B|C|D|K|P|U)$/i;
        if ( $saleProductType =~ $reAlbumLevelProduct && !exists $hProductTrackMap->{ $saleProductTrack } ) {
            Log->info( "track status set to Not Included on product $saleProductID sale "
                . $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kSaleID() ]
            );

            # !!! These sales should still be skipped, but we don't want them
            # !!! to appear in the unallocated sales report anymore.
            #
            # $self->logMissedSale(
            #     $missedSalesFD,
            #     $sale,
            #     RPS::ArtistRoyalty::Fast::Mapped::MissedSales::kNoTrackIncluded,
            #     $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractID() ]
            # );

            return;
        }
    }


    # !!! I can probably eliminate the padding

    my $formatString = '%12d'                                                       # payee_id
      . "\t" . '%12d'                                                               # album_id
      . "\t" . '%12d'                                                               # artist_contract_id
      . "\t" . RPS::ArtistRoyalty::Fast::Mapped::Data::kTypeSale . "\t" . '%12d'    # track_id
      . "\t" . '%12d'                                                               # term_id
      . "\t" . '%1d'                                                                # crossedflag
      . "\t" . '%s'                                                                 # region_id  (needs to be a string to preserve NULL)
      . "\t" . '%12d'                                                               # income_source_id
      . "\t" . '%12d'                                                               # channel_id
      . "\t" . '%12d'                                                               # price_level_id
      . "\t" . '%20.08f'                                                            # rate
      . "\t" . '%20.08f'                                                            # price
      . "\t" . '%20.08f'                                                            # conversion_rate
      . "\t" . '%12d'                                                               # sale_id
      . "\t" . '%12d'                                                               # product_id
      . "\t" . '%12d'                                                               # sales
      . "\t" . '%12d'                                                               # returns
      . "\t" . '%20.08f'                                                            # net revenue
      . "\t" . '%12d'                                                               # units liquidated
      . "\t" . '%20.08f'                                                            # revenue liquidated
      . "\t" . '%1d'                                                                # is digital flag
      . "\t" . '%s'      # Using a string for now to see if I can replicate old code bug.  this should be a fixed point value.
      . "\t" . '%12d'    # prorate track count (FB113)
      . "\t" . '%20.08f'                                                            # gross revenue
      . "\n";


    print $outFD sprintf(
        $formatString,
        $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kArtistPayeeID() ],
        $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kAlbumID() ],
        $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractID() ],
        $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kTrackID() ],
        $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kArtistContractTermID() ],
        $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kCrossed() ],
        $term->[ RPS::ArtistRoyalty::Fast::Static::Terms::kRegionID() ],
        $incomeSourceID,
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kChannel() ],
        $priceLevel,
        $rate,
        $price,
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kConversionRate() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kSaleID() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kProductID() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kSales() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kReturns() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kTotalRevenue() ],
        0,
        0,
        $productTerm->[ RPS::ArtistRoyalty::Fast::Static::ProductTerms::kIsDigital() ],
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kDistributionFee() ],
        $prorate,    # FB113
        $sale->[ RPS::ArtistRoyalty::Fast::Static::Sales::kGrossRevenue() ],
    );

}

sub _determineIncomeSourceID {
    my ( $self, $productType, $formatType ) = @_;
    my $incomeSourceID;
    $formatType  = uc($formatType);
    $productType = uc($productType);

    # Video tracks will be treated like digital tracks.
    #
    if ( 'V' eq $formatType ) {
        if ( 'T' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalTrack;
        }

    } elsif ( 'D' eq $formatType ) {
        if ( 'T' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalTrack;
        } elsif ( 'A' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalAlbum;
        }

    } elsif ( 'H' eq $formatType ) {
        if ( 'T' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalTrackPremium;
        } elsif ( 'A' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalAlbumPremium;
        }

    } elsif ( 'I' eq $formatType ) {
        if ( 'T' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalTrackUpgrade;
        } elsif ( 'A' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalAlbumUpgrade;
        }

    } elsif ( 'S' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalStream;
    } elsif ( 'T' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDigitalTethered;
    } elsif ( 'R' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceRingtone;
    } elsif ( 'B' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceBackground;
    } elsif ( 'P' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceVPD;
    } elsif ( 'E' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDualDownload;
    } elsif ( 'J' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceJukebox;
    } elsif ( 'O' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourcePerformanceIncome;

        # See RPS/File/Sale.pm for Ephemeral/Non-Ephemeral format definitions
        #
    } elsif ( 'L' eq $formatType
        || '!' eq $formatType
        || '@' eq $formatType
        || '#' eq $formatType
        || '$' eq $formatType
        || '%' eq $formatType
        || '[' eq $formatType
        || ']' eq $formatType
        || ':' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceEphemeral;
    } elsif ( 'N' eq $formatType
        || '&' eq $formatType
        || '*' eq $formatType
        || '(' eq $formatType
        || ')' eq $formatType
        || '_' eq $formatType
        || '{' eq $formatType
        || '}' eq $formatType
        || ';' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceNonEphemeral;
    } elsif ( 'X' eq $formatType ) {
        $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceOrchardSync;
    } else {
        if ( '2' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceCD;
        } elsif ( '1' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceLP;
        } elsif ( 'P' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceLP5;
        } elsif ( '4' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceCassette;
        } elsif ( 'M' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceMasterLicense;
        } elsif ( 'S' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceSyncLicense;
        } elsif ( '9' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDVD;
        } elsif ( 'U' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceBluRay;
        } elsif ( 'D' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDVDCDSet;
        } elsif ( '3' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceVHS;
        } elsif ( 'B' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceDblCD;
        } elsif ( 'C' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceCDSingle;
        } elsif ( 'K' eq $productType ) {
            $incomeSourceID = RPS::DB::Item::IncomeSource::kIncomeSourceCassette5;
        } else {
            Log->warn("WARNING - unable to determine income source id from productType $productType formatType $formatType");
        }

    }

    # !!! Add support for VPD when we have choosen codes.

    return $incomeSourceID;
}

sub _getTermObj {
    my ( $self, $productTerm, $termData ) = @_;

    # !!! I don't want to waste time allocating memory to hold this data.
    # !!! I think I might be able to simply bless the term data reference.
    # !!! My goal here is to attach the correct methods to the data.
    #

    # Do I use the $termData anyplace else in this code?  It not, why have it here?
    #
    my $termID = $productTerm->[RPS::ArtistRoyalty::Fast::Static::ProductTerms::kArtistContractTermID];

    return RPS::ArtistRoyalty::Fast::TermType->GetTermObj( $termID, $termData );

}

# JPK - Taken straight from the old code.
# Note that this is making DB::Item calls - I would rather extract this data.
my $gProductTypeIDMap;

sub _idToProductType {
    my ($id) = @_;

    if ( !$gProductTypeIDMap ) {
        $gProductTypeIDMap = {};

        my $all = RPS::DB::Item::ProductType->GetAll();
        while ( my $pt = $all->next() ) {
            $gProductTypeIDMap->{ $pt->product_type_id } = $pt->description;
        }
    }

    my $productTypeName = '(Missing)';

    if ( $gProductTypeIDMap->{$id} ) {
        $productTypeName = $gProductTypeIDMap->{$id};
    }

    return $productTypeName;
}

my $gSaleProductTypeIDMap;

sub _idToSaleProductType {
    my ( $self, $id ) = @_;

    if ( !$gSaleProductTypeIDMap ) {
        $gSaleProductTypeIDMap = {};

        my $all = RPS::DB::Item::SaleProductType->GetAll();
        while ( my $pt = $all->next() ) {
            $gSaleProductTypeIDMap->{ $pt->sale_product_type_id } = $pt->description;
        }
    }

    my $saleProductTypeName = '(Missing)';

    if ( $gSaleProductTypeIDMap->{$id} ) {
        $saleProductTypeName = $gSaleProductTypeIDMap->{$id};
    }

    return $saleProductTypeName;
}

my $gFormatTypeIDMap;

sub _idToFormatType {
    my ( $self, $id ) = @_;

    if ( !$gFormatTypeIDMap ) {
        $gFormatTypeIDMap = {};

        my $all = RPS::DB::Item::Format->GetAll();
        while ( my $ft = $all->next() ) {
            $gFormatTypeIDMap->{ $ft->format_type } = $ft->format_name;
        }
    }

    my $formatTypeName = '(Missing)';

    if ( $gFormatTypeIDMap->{$id} ) {
        $formatTypeName = $gFormatTypeIDMap->{$id};
    }

    return $formatTypeName;
}

my $gIncomeSourceIDMap;

sub _idToIncomeSourceAndPhysicalFlag {
    my ( $self, $id ) = @_;

    if ( !$gIncomeSourceIDMap ) {
        $gIncomeSourceIDMap = {};

        #         my $incomeSource = RPS::DB::Item::IncomeSource->Lookup(income_source_id => $incomeSourceID);

        my $all = RPS::DB::Item::IncomeSource->GetAll();
        while ( my $item = $all->next() ) {
            $gIncomeSourceIDMap->{ $item->income_source_id } = [ $item->description, $item->format ];
        }
    }

    my $name   = '(Unknown)';
    my $format = 0;

    my $data = $gIncomeSourceIDMap->{$id};
    if ($data) {
        $name   = $data->[0];
        $format = $data->[1];
    }

    return ( $name, $format );
}

my $gPriceLevelIDMap;

sub _idToPriceLevel {
    my ( $self, $id ) = @_;

    if ( !$gPriceLevelIDMap ) {
        $gPriceLevelIDMap = {};

        my $all = RPS::DB::Item::PriceLevel->GetAll();
        while ( my $pl = $all->next() ) {
            $gPriceLevelIDMap->{ $pl->price_level_id } = $pl->name;
        }
    }

    my $priceLevelName = '(Missing)';

    if ( $gPriceLevelIDMap->{$id} ) {
        $priceLevelName = $gPriceLevelIDMap->{$id};
    }

    return $priceLevelName;
}

my $gChannelIDMap;

sub _idToChannel {
    my ( $self, $id ) = @_;

    if ( !$gChannelIDMap ) {
        $gChannelIDMap = {};

        my $allChannels = RPS::DB::Item::Channel->GetAll();
        while ( my $is = $allChannels->next() ) {
            $gChannelIDMap->{ $is->channel_id } = $is->name;
        }
    }
    my $channelName = '(Missing)';

    if ( $gChannelIDMap->{$id} ) {
        $channelName = $gChannelIDMap->{$id};
    }

    return $channelName;
}

1;
