The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Business::Colissimo;

use 5.006;
use strict;
use warnings;

use Barcode::Code128;

=head1 NAME

Business::Colissimo - Shipping labels for ColiPoste

=head1 VERSION

Version 0.2100

=cut

our $VERSION = '0.2100';

my %product_codes = (access_f => '8L', 
		     expert_f => '8V', 
		     expert_om => '7A', 
		     expert_i => 'CY', 
		     expert_i_kpg => 'EY'
    );

my %test_account = (access_f => '964744', 
		    expert_f => '964744',
		    expert_om => '964744', 
		    expert_i => '964744', 
		    expert_kpg => '900000',  
    );

my %test_ranges = (access_f => [qw/4139207826 4139212825/],
		   expert_f => [qw/5649204247 5649209246/],
		   expert_om => [qw/5389439016 5389444015/],
		   expert_i => [qw/00005801 00055800/],
		   expert_i_kpg => [qw/00000001 00051000/],
		    );
		   
my %attributes = (parcel_number => 'parcel number', 
		  postal_code => 'postal code', 
		  customer_number => 'customer number',
		  weight => 'parcel weight',
		  not_mechanisable => 'not mechanisable',

		  # expert modes
		  cod => 'cash on delivery',
		  level => 'insurance/recommendation level',

          # expert_om/expert_i modes
          ack_receipt => 'acknowledgement of receipt',
          
          # expert_om mode
          duty_free => 'customs duty free (FTD)',
          
          # expert_i mode
          country_code => 'country code',
                  
		  # barcode image
		  scale => 'barcode image scale factor',
		  height => 'barcode image height',
		  padding => 'barcode image padding',

		  # testing
		  test => 'testing',
    );

my %logo_files = (access_f => 'AccessF',
		  expert_f => 'ExpertF',
		  expert_om => 'ExpertOM',
		  expert_i => 'ExpertInter',
		  expert_i_kpg => 'ExpertInter',                  
    );

my %countries = (AT => {epg => 1},
                 AU => {kpg => 1},
                 BE => {epg => 1},
                 BR => {kpg => 1},
                 CH => {epg => 1},
                 CN => {kpg => 1},
                 DE => {epg => 1},
                 DK => {epg => 1},
                 ES => {epg => 1},
                 FI => {epg => 1},
                 FR => {epg => 1},
                 GB => {epg => 1},
                 GR => {epg => 1},
                 HK => {kpg => 1},
                 IE => {epg => 1},
                 IL => {kpg => 1},
                 IS => {epg => 1},
                 IT => {epg => 1},
                 JP => {kpg => 1},
                 KR => {kpg => 1},
                 LU => {epg => 1},
                 MA => {kpg => 1},
                 NO => {epg => 1},
                 PT => {epg => 1},
                 RU => {kpg => 1},
                 SG => {kpg => 1},
                 SE => {epg => 1},
                 VN => {kpg => 1},
                 US => {kpg => 1},
    );

=head1 SYNOPSIS 

    use Business::Colissimo;

    $colissimo = Business::Colissimo->new(mode => 'access_f',
                               customer_number => '900001',
                               parcel_number => '2052475203',
                               postal_code => '72240',
                               weight => 120);

    # get logo file name
    $colissimo->logo;

    # produce barcode images
    $colissimo->barcode('tracking', spacing => 1);
    $colissimo->barcode('shipping', spacing => 1);

    # customer number
    $colissimo->customer_number('900001')
    # parcel number from your alloted range numbers
    $colissimo->parcel_number('2052475203');

    # country code for recipient (expert_i and expert_i_kpg mode)
    $colissimo->country_code('DE');

    # postal code for recipient
    $colissimo->postal_code('72240');

    # add weight in grams
    $colissimo->weight(250);

    # not mechanisable option
    $colissimo->not_mechanisable(1);
    
    # cash on delivery option (expert mode only)
    $colissimo->cod(1);

    # insurance level (expert mode only)
    $colissimo->level('01');

    # recommendation level (expert mode only)
    $colissimo->level('21');

    # set scale in pixels for barcode image (default: 1)
    $colissimo->scale(2);

    # set height in pixels for barcode image (default: 77)
    $colissimo->height(100);

=head1 DESCRIPTION

Business::Colissimo helps you to produce shipping labels
for the following ColiPoste services:

=over 4

=item Access France
    
    $colissimo = Business::Colissimo->new(mode => 'access_f');

=item Expert France

    $colissimo = Business::Colissimo->new(mode => 'expert_f');

=item Expert Outre Mer

    $colissimo = Business::Colissimo->new(mode => 'expert_om');

=item Expert International

    KPG countries:

    $colissimo = Business::Colissimo->new(mode => 'expert_i_kpg');

    Countries outside of KPG:

    $colissimo = Business::Colissimo->new(mode => 'expert_i');

=back

=head1 METHODS

=head2 new

    $colissimo = Business::Colissimo->new(mode => 'access_f',
         customer_number => '900001',
         parcel_number => '2052475203',
         postal_code => '72240',
         weight => 250);

    $colissimo = Business::Colissimo->new(mode => 'expert_f',
         customer_number => '900001',
         parcel_number => '2052475203',
         postal_code => '72240',
         weight => 250,
         cod => 1,
         level => '01');

=cut

sub new {
    my ($class, $self, %args);

    $class = shift;
    %args = @_;

    unless (defined $args{mode} && $product_codes{$args{mode}}) {
	die 'Please select valid mode for ', __PACKAGE__;
    }

    $self = {mode => delete $args{mode},
	     parcel_number => '',
         country_code => '',
	     postal_code => '',
	     customer_code => '',
	     not_mechanisable => '0',

	     # expert 
	     cod => '0',
	     level => '00',
         ack_receipt => '0',
         duty_free => '0',

         # barcode image
	     scale => 1,
	     height => 77,
             padding => 20,

	     # testing
	     test => 0,
    };

    if ($self->{mode} eq 'expert_i' || $self->{mode} eq 'expert_i_kpg') {
        $self->{international} = 1;
    }
    else {
        $self->{international} = 0;
    }
    
    bless $self, $class;

    for my $name (keys %args) {
	if (exists $attributes{$name}) {
	    $self->$name($args{$name});
	}
    }

    return $self;
}

=head2 barcode

Produces the tracking barcode:

    $colissimo->barcode('tracking');

Same with proper spacing for the shipping label:

    $colissimo->barcode('tracking', spacing => 1);

Produces the sorting barcode:

    $colissimo->barcode('sorting');

Same with proper spacing for the shipping label:

    $colissimo->barcode('sorting', spacing => 1);

=cut

sub barcode {
    my ($self, $type, %args) = @_;
    my ($barcode, $parcel_number, $control);

    $barcode = $product_codes{$self->{mode}};

    unless (length($self->{parcel_number})) {
        die "Missing $attributes{parcel_number}";
    }
    
    if ($type eq 'sorting') {
        # check if we have everything we need
        my @required = qw/postal_code parcel_number customer_number weight/;

        if ($self->{international}) {
            push (@required, 'country_code');
        }
        
        for my $name (@required) {
            unless (length($self->{$name})) {
                die "Missing $attributes{$name} for mode $self->{mode}";
            }
        }
        
        # fixed sort code
        if ($self->{international}) {
            $barcode .= '2';
        }
        else {
            $barcode .= '1';
        }
        
        if ($self->{international}) {
            # recipient country code
            $barcode .= $self->country_code;

            # recipient postal code (first three characters, filled with zeroes if necessary)
            my $postal = $self->postal_code;

            if (length($postal) >= 3) {
                $barcode .= substr($postal, 0, 3);
            }
            else {
                $barcode .= $postal . ('0' x (3 - length($postal)));
            }
        }
        else {
            # recipient postal code
            $barcode .= $self->postal_code;
        }
        
        # customer code
        $control = $self->customer_number;

        # parcel weight
        $control .= $self->weight;

        # insurance/recommendation level
        $control .= $self->level;

        # not mechanisable 
        $control .= $self->not_mechanisable;

        if ($self->{mode} eq 'expert_om'
            || $self->international) {
            # combination of cash on delivery, customs duty free
            # and acknowledgement of receipt options
            $control .= $self->cod 
                + 2 * $self->duty_free
                + 4 * $self->ack_receipt;
        }
        else {
            # cash on delivery
            $control .= $self->cod;
        }

        # control link digit (last digit of parcel number)
        if ($self->international) {
            $control .= substr($self->parcel_number, 7, 1);
        } else {
            $control .= substr($self->parcel_number, 9, 1);
        }
    
        $barcode .= $control . $self->control_key($control);
    
  
        if ($args{spacing}) {
            return join(' ', substr($barcode, 0, 3),
                        substr($barcode, 3, 5),
                        substr($barcode, 8, 6),
                        substr($barcode, 14, 4),
                        substr($barcode, 18, 6));
        }
    }
    else {
        $parcel_number = $self->parcel_number;
        $barcode .= $parcel_number;
        $barcode .= $self->control_key($parcel_number);

        if ($self->{international} && $type eq 'tracking') {
            $barcode .= 'FR';
        }
   
        if ($args{spacing}) {
            if ($self->{international}) {
                return join(' ', substr($barcode, 0, 2),
                            substr($barcode, 2, 4),
                            substr($barcode, 6, 4),
                            substr($barcode, 10, 3));
            }
            else {
                return join(' ', substr($barcode, 0, 2),
                            substr($barcode, 2, 5),
                            substr($barcode, 7, 5),
                            substr($barcode, 12, 1));
            }
        }
    }

    return $barcode;
}

=head2 barcode_image

Produces PNG image for tracking barcode:

    $colissimo->barcode_image('tracking');

Produces PNG image for sorting barcode:

    $colissimo->barcode_image('sorting');

Produces PNG image for arbitrary barcode:

    $colissimo->barcode_image('8L20524752032');

The scale of the image can be changed for each
barcode individually:

    $colissimo->barcode_image('8L20524752032', scale => 2);

The default scale is set to 1, because that produces
images with the right number of pixels to include them
into PDF with L<PDF::API2>, which uses 72dpi resolution
for images unless you specify width and height explicitly
(see L<PDF::API2::Content>).

The formula for calculating width in mm for a 72dpi
resolution is as follows:

    (1px * 25.4) / 72dpi

This fits into Colissimo's requirement for the basic
module (narrowest element of the bar code) of 
0.33 to 0.375 mm.

=cut

sub barcode_image {
    my ($self, $type, %args) = @_;
    my ($barcode, $image, $code128, $png, $scale, $height, $padding);

    if ($type eq 'tracking' || $type eq 'sorting') {
	$barcode = $self->barcode($type);
    }
    else {
	$barcode = $type;
    }

    $code128 = Barcode::Code128->new;
    $code128->border(0);

    # scale
    if ($scale = $self->{scale} || $args{scale}) {
	$code128->scale($scale);
    }

    # height 
    if ($height = $self->{height} || $args{height}) {
	$code128->height($height);
    }

    # padding
    $padding = $self->{padding} || $args{padding};
    $code128->padding($padding);

    $code128->show_text(0);

    $png = $code128->png($barcode);
}


=head2 logo

Returns logo file name for selected service.

    $colissimo->logo;

=cut

sub logo {
    my $self = shift;

    return $logo_files{$self->{mode}} . '.bmp';
}

=head2 test

Toggles testing.

    $colissimo->test(1);

=cut

sub test {
    my $self = shift;

    if (@_ > 0 && defined $_[0]) {
	$self->{test} = $_[0];
	
	if ($self->{test} && ! $self->{customer_number}) {
	    # use predefined customer number for tests
	    $self->{customer_number} = $test_account{$self->{mode}};
	}
    }

    return $self->{test};
}

=head2 scale

Get current scale for barcode image:

    $colissimo->scale;

Set current scale for barcode image:

    $colissimo->scale(3);

=cut

sub scale {
    my $self = shift;
    my $scale;

    if (@_ > 0 && defined $_[0]) {
	$scale = $_[0];

	if ($scale !~ /^\d+$/) {
	    die 'Please provide valid scale factor';
	}

	$self->{scale} = $scale;
    }

    return $self->{scale};
}

=head2 height

Get current height for barcode image:

    $colissimo->height;

Set current height for barcode image:

    $colissimo->height(100);

=cut

sub height {
    my $self = shift;
    my $height;

    if (@_ > 0 && defined $_[0]) {
	$height = $_[0];

	if ($height !~ /^\d+$/) {
	    die 'Please provide valid height';
	}

	$self->{height} = $height;
    }

    return $self->{height};
}

=head2 padding

Get current padding for barcode image:

    $colissimo->padding;

Set current padding for barcode image:

    $colissimo->padding(0);

=cut

sub padding {
    my $self = shift;
    my $padding;

    if (@_ > 0 && defined $_[0]) {
	$padding = $_[0];

	if ($padding !~ /^\d+$/) {
	    die 'Please provide valid padding';
	}

	$self->{padding} = $padding;
    }

    return $self->{padding};
}

=head2 customer_number

Get current customer number:

    $colissimo->customer_number;

Set current customer number:

    $colissimo->customer_number('900001');

=cut

sub customer_number {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
	$number = $_[0];
	
        $number =~ s/\s+//g;

	if ($number !~ /^\d{6}$/) {
	    die 'Please provide valid customer number (6 digits) for barcode';
	}

	$self->{customer_number} = $number;
    }    

    return $self->{customer_number};
}

=head2 parcel_number

Get current parcel number:

    $colissimo->parcel_number;
    
Set current parcel number:

    $colissimo->parcel_number('2052475203');

=cut

sub parcel_number {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
        $number = $_[0];
	
        $number =~ s/\s+//g;

        if ($self->{international}) {
            if ($number !~ /^\d{8}$/) {
                die 'Please provide valid parcel number (8 digits) for barcode';
            }
        }
        else {
            if ($number !~ /^\d{10}$/) {
                die 'Please provide valid parcel number (10 digits) for barcode';
            }
        }
        
        $self->{parcel_number} = $number;
    }

    return $self->{parcel_number};
}

=head2 country_code

Get current country code:

    $colissimo->country

Set current country code:

    $colissimo->country('BE');

The country code is defined in the ISO 3166 standard.

Switches to expert_i_kpg mode automatically.

=cut

sub country_code {
    my $self = shift;
    my $string;

    if (@_ > 0 && defined $_[0]) {
        $string = uc($_[0]);
        $string =~ s/\s+//g;

        if ($string !~ /^[A-Z]{2}$/) {
            die 'Please provide valid country code for barcode';
        }

        if ($self->{mode} eq 'access_f'
            || $self->{mode} eq 'expert_f') {
            if ($string ne 'FR') {
                die "Only France is allowed as delivery country for $self->{mode}.\n";
            }
        }
        elsif ($self->{mode} eq 'expert_om') {
            # no country code requirements yet
        }
        elsif ($string eq 'FR') {
            die "France isn't allowed as delivery country for $self->{mode}.\n";
        }

        if (exists $countries{$string} && $countries{$string}->{kpg}) {
            $self->{mode} = 'expert_i_kpg';
        }
        
        $self->{country_code} = $string;
    }

    return $self->{country_code};
}

=head2 postal_code

Get current postal code:

    $colissimo->postal_code

Set current postal code:

    $colissimo->postal_code('72240');

=cut

sub postal_code {
    my $self = shift;
    my $string; 

    if (@_ > 0 && defined $_[0]) {
        $string = $_[0];
	
        $string =~ s/\s+//g;

        if ($self->{international}) {
            if ($string eq '0' || $string !~ /^[A-Z0-9]{1,10}$/) {
                die 'Please provide valid postal code (1-10 alphanumerics) for barcode';
            }
        }
        else {
            if ($string !~ /^[A-Z0-9]{5}$/) {
                die 'Please provide valid postal code (5 alphanumerics) for barcode';
            }
        }
        
        $self->{postal_code} = $string;
    }

    return $self->{postal_code};
}

=head2 weight

Get current weight:

   $colissimo->weight;

Set weight in grams:

   $colissimo->weight(250);

=cut

sub weight {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
	$number = $_[0];
	
        $number =~ s/\s+//g;

	if ($number !~ /^\d{1,5}$/) {
	    die 'Please provide valid parcel weight (less than 100 kg) for barcode';
	}

	$self->{weight} = sprintf('%04d', int($number / 10));
    }

    return $self->{weight};
}

=head2 not_mechanisable

Get current value of not mechanisable option:

    $colissimo->not_mechanisable;

Set current value of not mechanisable option:

    $colissimo->not_mechanisable(1);

Possible values are 0 (No) and 1 (Yes).

=cut

sub not_mechanisable {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
	$number = $_[0];
	
        $number =~ s/\s+//g;

	if ($number !~ /^[01]$/) {
	    die 'Please provide valid value for not mechanisable (0 or 1)';
	}

	$self->{not_mechanisable} = $number;
    }

    return $self->{not_mechanisable};
}

=head2 cod

Get current value of cash on delivery option:

    $colissimo->cod;

Set current value of cash on delivery option:

    $colissimo->cod(1);

The cash on delivery option is available only in export mode,
possible values are 0 (No) and 1 (Yes).

=cut

sub cod {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
	$number = $_[0];
	
        $number =~ s/\s+//g;

	if ($number !~ /^[01]$/) {
	    die 'Please provide valid value for cash on delivery option (0 or 1)';
	}

	if ($self->{mode} eq 'access' 
	    && $number eq '1') {
	    die 'Cash on delivery option not available in access mode.';
	}

	$self->{cod} = $number;
    }

    return $self->{cod};
}

=head2 level

Get current insurance resp. recommendation level:

    $colissimo->level;

Set current insurance resp. recommendation level:

    $colissimo->level('O1');
    $colissimo->level('21');

The level option is only available in expert mode, 
possible values are 01 ... 10 for insurance level
and 21 ... 23 for recommendation level.

=cut

sub level {
    my $self = shift;
    my $number;

   if (@_ > 0 && defined $_[0]) {
	$number = $_[0];
	
        $number =~ s/\s+//g;

	if ($number !~ /^(0\d|10|2[123])$/ ) {
	    die 'Please provide valid value for insurance/recommendation level.';
	}

	if ($self->{mode} eq 'access_f' 
	    && $number ne '00') {
	    die 'Insurance/recommendation level not available in access mode.';
	}

	$self->{level} = $number;
    }

    return $self->{level};
}

=head2 ack_receipt

Get current value for acknowledgement of receipt (AR):

    $colissimo->ack_receipt;

Set current value for acknowledgement of receipt (AR):

    $colissimo->ack_receipt(1);

Returns 1 if acknowledgement of receipt is enabled, 0 otherwise.

The ack_receipt option is only available in expert_om and expert_i modes,
possible values are 0 (No) and 1 (Yes).

=cut

sub ack_receipt {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
        $number = $_[0];
        $number =~ s/\s+//g;

        if ($number !~ /^[01]$/) {
            die 'Please provide valid value for acknowledgement of receipt option (0 or 1)';
        }

        unless ($number == 0 || $self->international || $self->{mode} eq 'expert_om') {
            die 'Acknowledgement of receipt option only available in expert_om and expert_i modes.';
        }
        
        $self->{ack_receipt} = $number;
    }

    return $self->{ack_receipt};
}

=head2 duty_free

Get current value for customs duty free (FTD):

    $colissimo->duty_free;

Set current value for customs duty free (FTD):

    $colissimo->duty_free(1);

The custom duty free option is only available in expert_om mode,
possible values are 0 (No) and 1 (Yes).

=cut

sub duty_free {
    my $self = shift;
    my $number;

    if (@_ > 0 && defined $_[0]) {
        $number = $_[0];
        $number =~ s/\s+//g;

        if ($number !~ /^[01]$/) {
            die 'Please provide valid value for customs duty free option (0 or 1)';
        }

        unless ($number == 0 || $self->{mode} eq 'expert_om') {
            die 'Customs duty free option only available in expert_om mode.';
        }
        
        $self->{duty_free} = $number;
    }
    
    return $self->{duty_free};
}

=head2 international

Returns 1 on international (expert_i or expert_i_kpg) shippings,
0 otherwise.

=cut

sub international {
    return $_[0]->{international};
}

=head2 organisation

Returns the acronym of the inter-postal organisation (KPG or EPG)
corresponding to the destination country.

=cut

sub organisation {
    my $self = shift;
    
    if (exists $countries{$self->{country_code}}) {
        my $cref = $countries{$self->{country_code}};

        if ($cref->{epg}) {
            return 'EPG';
        }

        if ($cref->{kpg}) {
            return 'KPG';
        }
    }

    return '';
}

=head2 control_key

Returns control key for barcode.

=cut

sub control_key {
    my ($self, $characters) = @_;
    my (@codes, $even, $odd, $key, $mod);

    @codes = split(//, $characters);

    if ($self->international && @codes == 8) {
        # special case for tracking control keys
        # for international orders
        my @coefficients = (8, 6, 4, 2, 3, 5, 9, 7);

        while (@codes) {
            $key += shift(@codes) * shift(@coefficients);
        }

        $mod = $key % 11;

        if ($mod == 0) {
            return 5;
        }
        elsif ($mod == 1) {
            return 0;
        }
        else {
            return 11 - $mod;
        }
    }
    
    if (@codes % 2) {
	# pad characters for sorting control key
	unshift (@codes, '0');
    }

    while (@codes) {
	$odd += shift(@codes);
	$even += shift(@codes);
    }

    $key = (3 * $even) + $odd;
    $mod = $key % 10;

    return $mod ? 10 - $mod : 0;
}

=head1 AUTHOR

Stefan Hornburg (Racke), C<< <racke at linuxia.de> >>

=head1 BUGS

Please report any bugs or feature requests to C<bug-business-colissimo at rt.cpan.org>, or through
the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Business-Colissimo>.  I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.




=head1 SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc Business::Colissimo


You can also look for information at:

=over 4

=item * RT: CPAN's request tracker (report bugs here)

L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Business-Colissimo>

=item * AnnoCPAN: Annotated CPAN documentation

L<http://annocpan.org/dist/Business-Colissimo>

=item * CPAN Ratings

L<http://cpanratings.perl.org/d/Business-Colissimo>

=item * Search CPAN

L<http://search.cpan.org/dist/Business-Colissimo/>

=back


=head1 ACKNOWLEDGEMENTS

Thanks to Ton Verhagen for being a big supporter of my projects in all aspects.

=head1 LICENSE AND COPYRIGHT

Copyright 2011-2012 Stefan Hornburg (Racke).

This program is free software; you can redistribute it and/or modify it
under the terms of either: the GNU General Public License as published
by the Free Software Foundation; or the Artistic License.

See http://dev.perl.org/licenses/ for more information.


=cut

1; # End of Business::Colissimo