reorganize the importer code and document
authorTony Cook <tony@develop-help.com>
Thu, 1 Nov 2012 03:47:50 +0000 (14:47 +1100)
committerTony Cook <tony@develop-help.com>
Mon, 5 Nov 2012 00:02:32 +0000 (11:02 +1100)
12 files changed:
MANIFEST
site/cgi-bin/modules/BSE/ImportSourceBase.pm [deleted file]
site/cgi-bin/modules/BSE/ImportSourceXLS.pm [deleted file]
site/cgi-bin/modules/BSE/ImportTargetArticle.pm [deleted file]
site/cgi-bin/modules/BSE/ImportTargetBase.pm [deleted file]
site/cgi-bin/modules/BSE/ImportTargetProduct.pm [deleted file]
site/cgi-bin/modules/BSE/Importer.pm
site/cgi-bin/modules/BSE/Importer/Source/Base.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Importer/Source/XLS.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Importer/Target/Article.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Importer/Target/Base.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Importer/Target/Product.pm [new file with mode: 0644]

index 9ff108f..9cfb12a 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -106,11 +106,11 @@ site/cgi-bin/modules/BSE/ImageHandler/Base.pm
 site/cgi-bin/modules/BSE/ImageHandler/Flash.pm
 site/cgi-bin/modules/BSE/ImageHandler/Img.pm
 site/cgi-bin/modules/BSE/Importer.pm
-site/cgi-bin/modules/BSE/ImportSourceBase.pm
-site/cgi-bin/modules/BSE/ImportSourceXLS.pm
-site/cgi-bin/modules/BSE/ImportTargetArticle.pm
-site/cgi-bin/modules/BSE/ImportTargetBase.pm
-site/cgi-bin/modules/BSE/ImportTargetProduct.pm
+site/cgi-bin/modules/BSE/Importer/Source/Base.pm
+site/cgi-bin/modules/BSE/Importer/Source/XLS.pm
+site/cgi-bin/modules/BSE/Importer/Target/Article.pm
+site/cgi-bin/modules/BSE/Importer/Target/Base.pm
+site/cgi-bin/modules/BSE/Importer/Target/Product.pm
 site/cgi-bin/modules/BSE/Index.pm
 site/cgi-bin/modules/BSE/Index/Base.pm
 site/cgi-bin/modules/BSE/Index/BSE.pm
diff --git a/site/cgi-bin/modules/BSE/ImportSourceBase.pm b/site/cgi-bin/modules/BSE/ImportSourceBase.pm
deleted file mode 100644 (file)
index eb773b0..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-package BSE::ImportSourceBase;
-use strict;
-use Config;
-
-our $VERSION = "1.000";
-
-sub new {
-  my ($class, %opts) = @_;
-
-  my $importer = delete $opts{importer};
-  my $opts = delete $opts{opts};
-
-  return bless
-    {
-    }, $class;
-}
-
-1;
diff --git a/site/cgi-bin/modules/BSE/ImportSourceXLS.pm b/site/cgi-bin/modules/BSE/ImportSourceXLS.pm
deleted file mode 100644 (file)
index 667ea3d..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-package BSE::ImportSourceXLS;
-use strict;
-use base 'BSE::ImportSourceBase';
-use Spreadsheet::ParseExcel;
-
-our $VERSION = "1.000";
-
-sub new {
-  my ($class, %opts) = @_;
-
-  my $self = $class->SUPER::new(%opts);
-
-  my $importer = delete $opts{importer};
-  my $opts = delete $opts{opts};
-
-  $self->{sheet} = $importer->cfg_entry("sheet", 1);
-  $self->{skiprows} = $importer->cfg_entry('skiprows', 1);
-
-  return $self;
-}
-
-sub each_row {
-  my ($self, $importer, $filename) = @_;
-
-  my $parser = Spreadsheet::ParseExcel->new;
-  my $wb = $parser->Parse($filename)
-    or die "Could not parse $filename";
-  $self->{sheet} <= $wb->{SheetCount}
-    or die "No enough worksheets in input\n";
-  $self->{ws} = ($wb->worksheets)[$self->{sheet}-1]
-    or die "No worksheet found at $self->{sheet}\n";
-
-  my ($minrow, $maxrow) = $self->{ws}->RowRange;
-  for my $rownum ($self->{skiprows} ... $maxrow) {
-    $self->{rownum} = $rownum;
-    $importer->row($self);
-  }
-}
-
-sub get_column {
-  my ($self, $colnum) = @_;
-
-  my $cell = $self->{ws}->get_cell($self->{rownum}, $colnum-1);
-  if (defined $cell) {
-    return $cell->value;
-  }
-  else {
-    return '';
-  }
-}
-
-sub rowid {
-  my $self = shift;
-
-  return "Row " . ($self->{rownum}+1);
-}
-
-1;
diff --git a/site/cgi-bin/modules/BSE/ImportTargetArticle.pm b/site/cgi-bin/modules/BSE/ImportTargetArticle.pm
deleted file mode 100644 (file)
index 76af2a3..0000000
+++ /dev/null
@@ -1,235 +0,0 @@
-package BSE::ImportTargetArticle;
-use strict;
-use base 'BSE::ImportTargetBase';
-use BSE::API qw(bse_make_article bse_add_image bse_add_step_parent);
-use Articles;
-use Products;
-use OtherParents;
-
-our $VERSION = "1.001";
-
-sub new {
-  my ($class, %opts) = @_;
-
-  my $self = $class->SUPER::new(%opts);
-
-  my $importer = delete $opts{importer};
-
-  my $map = $importer->maps;
-  defined $map->{title}
-    or die "No title mapping found\n";
-
-  $self->{use_codes} = $importer->cfg_entry('codes', 0);
-  $self->{code_field} = $importer->cfg_entry("code_field", $self->default_code_field);
-
-  $self->{parent} = $importer->cfg_entry("parent", $self->default_parent);
-
-  if ($self->{use_codes} && !defined $map->{$self->{code_field}}) {
-    die "No product_code mapping found with 'codes' enabled\n";
-  }
-  $self->{ignore_missing} = $importer->cfg_entry("ignore_missing", 1);
-  $self->{reset_images} = $importer->cfg_entry("reset_images", 0);
-  $self->{reset_steps} = $importer->cfg_entry("reset_steps", 0);
-
-  return $self;
-}
-
-sub start {
-  my ($self) = @_;
-
-  $self->{parent_cache} = {};
-  $self->{leaves} = [];
-  $self->{parents} = [];
-}
-
-sub xform_entry {
-  my ($self, $importer, $entry) = @_;
-
-  $entry->{title} =~ /\S/
-    or die "title blank\n";
-
-  $entry->{title} =~ /\n/
-    and die "Title may not contain newlines";
-  $entry->{summary}
-    or $entry->{summary} = $entry->{title};
-  $entry->{description}
-    or $entry->{description} = $entry->{title};
-  $entry->{body}
-    or $entry->{body} = $entry->{title};
-}
-
-sub children_of {
-  my ($self, $parent) = @_;
-
-  Articles->children($parent);
-}
-
-sub make_parent {
-  my ($self, $importer, %entry) = @_;
-
-  return bse_make_article(%entry);
-}
-
-sub find_leaf {
-  my ($self, $leaf_id) = @_;
-
-  $leaf_id =~ tr/A-Za-z0-9_/_/cds;
-
-  my ($leaf) = Articles->getBy($self->{code_field}, $leaf_id)
-    or return;
-
-  return $leaf;
-}
-
-sub make_leaf {
-  my ($self, $importer, %entry) = @_;
-
-  return bse_make_article(%entry);
-}
-
-sub fill_leaf {
-  my ($self, $importer, $leaf, %entry) = @_;
-
-  if ($entry{tags}) {
-print "Setting tags $leaf->{id}: $entry{tags}\n";
-    my @tags = split '/', $entry{tags};
-    my $error;
-    unless ($leaf->set_tags(\@tags, \$error)) {
-      die "Error setting tags: $error";
-    }
-  }
-
-  return 1;
-}
-
-sub row {
-  my ($self, $importer, $entry, $parents) = @_;
-
-  $self->xform_entry($importer, $entry);
-  
-  $entry->{parentid} = $self->_find_parent($importer, $self->{parent}, @$parents);
-  my $leaf;
-  if ($self->{use_codes}) {
-    my $leaf_id = $entry->{$self->{code_field}};
-    
-    $leaf = $self->find_leaf($leaf_id);
-  }
-  if ($leaf) {
-    @{$leaf}{keys %$entry} = values %$entry;
-    $leaf->save;
-    $importer->info("Updated $leaf->{id}: $entry->{title}");
-    if ($self->{reset_images}) {
-      $leaf->remove_images($importer->cfg);
-      $importer->info(" $leaf->{id}: Reset images");
-    }
-    if ($self->{reset_steps}) {
-      my @steps = OtherParents->getBy(childId => $leaf->{id});
-      for my $step (@steps) {
-       $step->remove;
-      }
-    }
-  }
-  else {
-    $leaf = $self->make_leaf
-      (
-       $importer, 
-       cfg => $importer->cfg,
-       %$entry
-      );
-    $importer->info("Added $leaf->{id}: $entry->{title}");
-  }
-  for my $image_index (1 .. 10) {
-    my $file = $entry->{"image${image_index}_file"};
-    $file
-      or next;
-    my $full_file = $importer->find_file($file);
-
-    unless ($full_file) {
-      $self->{ignore_missing}
-       and next;
-      die "File '$file' not found for image$image_index\n";
-    }
-
-    my %opts = ( file => $full_file );
-    for my $key (qw/alt name url storage/) {
-      my $fkey = "image${image_index}_$key";
-      $entry->{$fkey}
-       and $opts{$key} = $entry->{$fkey};
-    }
-    
-    my %errors;
-    my $im = bse_add_image($importer->cfg, $leaf, %opts, 
-                          errors => \%errors);
-    $im 
-      or die join(", ",map "$_: $errors{$_}", keys %errors), "\n";
-    $importer->info(" $leaf->{id}: Add image '$file'");
-  }
-  for my $step_index (1 .. 10) {
-    my $step_id = $entry->{"step$step_index"};
-    $step_id
-      or next;
-    my $step;
-    if ($step_id =~ /^\d+$/) {
-      $step = Articles->getByPkey($step_id);
-    }
-    else {
-      $step = Articles->getBy(linkAlias => $step_id);
-    }
-    $step
-      or die "Cannot find stepparent with id $step_id\n";
-
-    bse_add_step_parent($importer->cfg, child => $leaf, parent => $step);
-  }
-  $self->fill_leaf($importer, $leaf, %$entry);
-  push @{$self->{leaves}}, $leaf;
-}
-
-sub _find_parent {
-  my ($self, $importer, $parent, @parents) = @_;
-
-  @parents
-    or return $parent;
-  my $cache = $self->{parent_cache};
-  unless ($cache->{$parent}) {
-    my @kids = $self->children_of($parent);
-    $cache->{$parent} = \@kids;
-  }
-
-  my $title = shift @parents;
-  my ($cat) = grep lc $_->{title} eq lc $title, @{$cache->{$parent}};
-  unless ($cat) {
-    my %opts =
-      (
-       cfg => $importer->cfg,
-       parentid => $parent,
-       title => $title,
-       body => $title,
-      );
-    $self->{catalog_template}
-      and $opts{template} = $self->{catalog_template};
-    $cat = $self->make_parent($importer, %opts);
-    $importer->info("Add parent $cat->{id}: $title");
-    push @{$cache->{$parent}}, $cat;
-  }
-
-  unless ($self->{catseen}{$cat->{id}}) {
-    $self->{catseen}{$cat->{id}} = 1;
-    push @{$self->{parents}}, $cat;
-  }
-
-  return $self->_find_parent($importer, $cat->{id}, @parents);
-}
-
-sub default_parent { -1 }
-
-sub default_code_field { "linkAlias" }
-
-sub leaves {
-  return @{$_[0]{leaves}}
-}
-
-sub parents {
-  return @{$_[0]{parents}}
-}
-
-1;
diff --git a/site/cgi-bin/modules/BSE/ImportTargetBase.pm b/site/cgi-bin/modules/BSE/ImportTargetBase.pm
deleted file mode 100644 (file)
index e8c0057..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-package BSE::ImportTargetBase;
-use strict;
-
-our $VERSION = "1.000";
-
-sub new {
-  my ($class, %opts) = @_;
-
-  my $self = bless {}, $class;
-
-  return $self;
-}
-
-1;
diff --git a/site/cgi-bin/modules/BSE/ImportTargetProduct.pm b/site/cgi-bin/modules/BSE/ImportTargetProduct.pm
deleted file mode 100644 (file)
index fe32f06..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-package BSE::ImportTargetProduct;
-use strict;
-use base 'BSE::ImportTargetArticle';
-use BSE::API qw(bse_make_product bse_make_catalog bse_add_image);
-use Articles;
-use Products;
-use BSE::TB::ProductOptions;
-use BSE::TB::ProductOptionValues;
-use BSE::TB::PriceTiers;
-
-our $VERSION = "1.001";
-
-sub new {
-  my ($class, %opts) = @_;
-
-  my $self = $class->SUPER::new(%opts);
-
-  my $importer = delete $opts{importer};
-
-  $self->{price_dollar} = $importer->cfg_entry('price_dollar', 0);
-  $self->{product_template} = $importer->cfg_entry('product_template');
-  $self->{catalog_template} = $importer->cfg_entry('catalog_template');
-  $self->{prodopt_value_sep} = $importer->cfg_entry("prodopt_separator", "|");
-  $self->{reset_prodopts} = $importer->cfg_entry("reset_prodopts", 1);
-
-  my $map = $importer->maps;
-  defined $map->{retailPrice}
-    or die "No retailPrice mapping found\n";
-
-  $self->{price_tiers} = +{ map { $_->id => $_ } BSE::TB::PriceTiers->all };
-
-  return $self;
-}
-
-sub xform_entry {
-  my ($self, $importer, $entry) = @_;
-
-  $self->SUPER::xform_entry($importer, $entry);
-
-  if ($self->{use_codes}) {
-    $entry->{product_code} =~ /\S/
-      or die "product_code blank with use_codes\n";
-  }
-  $entry->{retailPrice} =~ s/\$//; # in case
-
-  if ($entry->{retailPrice} =~ /\d/) {
-    $self->{price_dollar}
-      and $entry->{retailPrice} *= 100;
-  }
-  else {
-    $importer->warn("Warning: no price");
-    $entry->{retailPrice} = 0;
-  }
-}
-
-sub children_of {
-  my ($self, $parent) = @_;
-
-  return grep $_->{generator} eq 'Generate::Catalog',
-    Articles->children($parent);
-}
-
-sub make_parent {
-  my ($self, $importer, %entry) = @_;
-
-  return bse_make_catalog(%entry);
-}
-
-sub find_leaf {
-  my ($self, $leaf_id) = @_;
-
-  my ($leaf) = Products->getBy($self->{code_field}, $leaf_id)
-    or return;
-
-  if ($self->{reset_prodopts}) {
-    my @options = $leaf->db_options;
-    for my $option (@options) {
-      $option->remove;
-    }
-  }
-
-  return $leaf;
-}
-
-sub make_leaf {
-  my ($self, $importer, %entry) = @_;
-
-  return bse_make_product(%entry);
-}
-
-sub fill_leaf {
-  my ($self, $importer, $leaf, %entry) = @_;
-
-  my $ordering = time;
-  for my $opt_num (1 .. 5) {
-    my $name = $entry{"prodopt${opt_num}_name"};
-    my $values = $entry{"prodopt${opt_num}_values"};
-
-    defined $name && $name =~ /\S/ && $values =~ /\S/
-      or next;
-    my @values = split /\Q$self->{prodopt_value_sep}/, $values
-      or next;
-
-    my $option = BSE::TB::ProductOptions->make
-      (
-       product_id => $leaf->id,
-       name => $name,
-       display_order => $ordering++,
-      );
-
-    for my $value (@values) {
-      my $entry = BSE::TB::ProductOptionValues->make
-       (
-        product_option_id => $option->id,
-        value => $value,
-        display_order => $ordering++,
-       );
-    }
-  }
-
-  my %prices;
-  for my $tier_id (keys %{$self->{price_tiers}}) {
-    my $price = $entry{"tier_price_$tier_id"};
-    if (defined $price && $price =~ /\d/) {
-      $price =~ s/\$//; # in case
-      $price *= 100 if $self->{price_dollar};
-
-      $prices{$tier_id} = $price;
-    }
-  }
-
-  $leaf->set_prices(\%prices);
-
-  return $self->SUPER::fill_leaf($importer, $leaf, %entry);
-}
-
-sub default_parent { 3 }
-
-sub default_code_field { "product_code" }
-
-1;
index 936c797..4eb51d1 100644 (file)
@@ -2,13 +2,118 @@ package BSE::Importer;
 use strict;
 use Config;
 
-our $VERSION = "1.001";
+our $VERSION = "1.002";
+
+=head1 NAME
+
+BSE::Importer - generic import framework
+
+=head1 SYNOPSIS
+
+  [import profile foo]
+  map_title=1
+  map_linkAlias=2
+  set_template=common/default.tmpl
+  xform_customInt1 = int(rand 100)
+
+  use BSE::Importer;
+
+  my $profiles = BSE::Importer->profiles($cfg);
+  my $imp = BSE::Importer->new(cfg => $cfg, profile => $name);
+  $imp->process($filename);
+
+=head1 CONFIGURATION
+
+=head2 [import profiles]
+
+This can be used to provide display names for the defined profiles.
+
+Each key is a profile id and the value is the display name.
+
+=head2 [import profile I<name>]
+
+Defines an import profile, with the following keys:
+
+=over
+
+=item *
+
+C<< map_I<field> >> - defines which column number in the source which
+be mapped to the specificed field.  The value must be numeric.
+
+=item *
+
+C<< set_I<field> >> - set the value of the given field to a specific
+value.
+
+=item *
+
+C<< xform_I<field> >> - perl code to transform other input values to
+the value of the specified field.
+
+=item *
+
+C<cat1>, C<cat2>, C<cat3> - the chain of catalog names leading to a
+product.
+
+=item *
+
+C<file_path> - PATH format list of directories to search for attached
+files such as images.
+
+=item *
+
+C<source> - the source file type, the source module name is this value
+with C<BSE::Importer::Source::> prepended, so a value of C<XLS> will use the
+C<BSE::Importer::Source::XLS> module.
+
+=item *
+
+C<target> - the target object type, the target module name is this
+value with C<BSE::Importer::Target::> prepended, so a value of
+C<Product> will use the C<BSE::Importer::Target::Product> module.
+
+=back
+
+The source and target module may include their own configuration in
+this section.
+
+=head1 CLASS METHODS
+
+=over
+
+=item new()
+
+BSE::Importer->new(profile => $profile, ...)
+
+Create a new importer.  Parameters are:
+
+=over
+
+=item *
+
+C<profile> - the import profile to process
+
+=item *
+
+C<cfg> - the BSE::Cfg object to use for configuration
+
+=item *
+
+C<callback> - a sub ref to call for messages generated during
+processing.
+
+=back
+
+If the profile is invalid, new() with die with a newline terminated
+error message.
+
+=cut
 
 sub new {
   my ($class, %opts) = @_;
 
-  my $cfg = delete $opts{cfg}
-    or die "Missing cfg option\n";
+  my $cfg = delete $opts{cfg} || BSE::Cfg->single;
   my $profile = delete $opts{profile}
     or die "Missing profile option\n";
 
@@ -85,7 +190,7 @@ EOS
 
 
   my $source_type = $self->cfg_entry("source", "XLS");
-  $self->{source_class} = "BSE::ImportSource$source_type";
+  $self->{source_class} = "BSE::Importer::Source::$source_type";
 
   $self->_do_require($self->{source_class});
   $self->{source} = $self->{source_class}->new
@@ -95,7 +200,7 @@ EOS
     );
 
   my $target_type = $self->cfg_entry("target", "Product");
-  $self->{target_class} = "BSE::ImportTarget$target_type";
+  $self->{target_class} = "BSE::Importer::Target::$target_type";
   $self->_do_require($self->{target_class});
   $self->{target} = $self->{target_class}->new
     (
@@ -107,36 +212,39 @@ EOS
   return $self;
 }
 
+=item profiles()
+
+Return a hashref mapping profile names to display names.
+
+=cut
+
 sub profiles {
   my ($class, $cfg) = @_;
 
+  $cfg ||= BSE::Cfg->single;
+
   my %ids = $cfg->entries("import profiles");
   return \%ids;
 }
 
-sub section {
-  my ($self) = @_;
+=back
 
-  return "import profile $self->{profile}";
-}
+=head1 OBJECT METHODS
 
-sub maps {
-  $_[0]{map};
-}
+=head2 Processing
 
-sub cfg {
-  $_[0]{cfg};
-}
+=over
 
-sub profile {
-  $_[0]{profile};
-}
+=item process()
 
-sub cfg_entry {
-  my ($self, $key, $default) = @_;
+  $imp->process($filename);
 
-  return $self->{cfg}->entry($self->{section}, $key, $default);
-}
+Process the specified file, importing the data.
+
+Note that while the current source treats the argument as a filename,
+future sources may treat it as a URL or pretty much anything else.
+
+=cut
 
 sub process {
   my ($self, @source_info) = @_;
@@ -145,6 +253,58 @@ sub process {
   $self->{source}->each_row($self, @source_info);
 }
 
+=item errors()
+
+Valid after process() is called, return a list of errors encountered
+during processing.
+
+=cut
+
+sub errors {
+  $_[0]{errors}
+    and return @{$_[0]{errors}};
+
+  return;
+}
+
+=item leaves()
+
+Valid after process() is called, return a list of created imported
+objects.
+
+=cut
+
+sub leaves {
+  return $_[0]{target}->leaves;
+}
+
+=item parents()
+
+Valid after process() is called, return a list of synthesized parent
+objects (if any).
+
+=cut
+
+sub parents {
+  return $_[0]{target}->parents;
+}
+
+=back
+
+=head2 Internal
+
+These are for use my sources and targets.
+
+=over
+
+=item row()
+
+  $imp->row($source)
+
+Called by the source to process each row.
+
+=cut
+
 sub row {
   my ($self, $source) = @_;
 
@@ -179,12 +339,11 @@ sub row {
   }
 }
 
-sub errors {
-  $_[0]{errors}
-    and return @{$_[0]{errors}};
+=item _do_require()
 
-  return;
-}
+Load a module by module name and perform a default import.
+
+=cut
 
 sub _do_require {
   my ($self, $class) = @_;
@@ -197,6 +356,14 @@ sub _do_require {
   1;
 }
 
+=item info()
+
+  $imp->info(@msg)
+
+Called by various parts of the system to produce informational messages.
+
+=cut
+
 sub info {
   my ($self, @msg) = @_;
 
@@ -204,6 +371,15 @@ sub info {
     and $self->{callback}->("@msg");
 }
 
+=item warn()
+
+  $imp->warn(@msg);
+
+Called by various parts of the system to produce warning messaged for
+the current row.
+
+=cut
+
 sub warn {
   my ($self, @msg) = @_;
 
@@ -211,6 +387,17 @@ sub warn {
     and $self->{callback}->($self->{source}->rowid, ": @msg");
 }
 
+=item find_file()
+
+  my $fullname = $imp->find_file($filename)
+
+Search the configured file search path for C<$filename> and return the
+full path to the file.
+
+Returns an empty list on failure.
+
+=cut
+
 sub find_file {
   my ($self, $file) = @_;
 
@@ -222,12 +409,75 @@ sub find_file {
   return;
 }
 
-sub leaves {
-  return $_[0]{target}->leaves;
+=item section()
+
+Return the configuration section for the profile.
+
+=cut
+
+sub section {
+  my ($self) = @_;
+
+  return "import profile $self->{profile}";
 }
 
-sub parents {
-  return $_[0]{target}->parents;
+=item maps()
+
+Return a hash reference mapping field names to column numbers.
+
+=cut
+
+sub maps {
+  $_[0]{map};
+}
+
+=item cfg()
+
+Return the BSE::Cfg object used to configure the importer.
+
+=cut
+
+sub cfg {
+  $_[0]{cfg};
+}
+
+=item profile()
+
+Return the profile name.
+
+=cut
+
+sub profile {
+  $_[0]{profile};
+}
+
+=item cfg_entry()
+
+  my $value = $imp->cfg_entry($key, $default)
+
+Return the specified config value from the section for this profile.
+
+=cut
+
+sub cfg_entry {
+  my ($self, $key, $default) = @_;
+
+  return $self->{cfg}->entry($self->{section}, $key, $default);
 }
 
 1;
+
+=back
+
+=head1 SEE ALSO
+
+L<BSE::Importer::Source::Base>, L<BSE::Importer::Source::XLS>,
+L<BSE::Importer::Target::Base>, L<BSE::Importer::Target::Article>,
+L<BSE::Importer::Target::Product>,
+
+=head1 AUTHOR
+
+Tony Cook <tony@develop-help.com>
+
+=cut
+
diff --git a/site/cgi-bin/modules/BSE/Importer/Source/Base.pm b/site/cgi-bin/modules/BSE/Importer/Source/Base.pm
new file mode 100644 (file)
index 0000000..8224fff
--- /dev/null
@@ -0,0 +1,81 @@
+package BSE::Importer::Source::Base;
+use strict;
+use Config;
+
+our $VERSION = "1.001";
+
+=head1 NAME
+
+BSE::Importer::Source::Base - base class for importer sources
+
+=head1 SYNOPSIS
+
+  [import profile foo]
+  source=something derived from BSE::Importer::Source::Base
+
+=head1 DESCRIPTION
+
+This provides a trivial base class for importer sources.
+
+New sources should have this in their @ISA as it will be updated to
+provide default implementations of methods sources are required to
+provide.
+
+=head1 METHODS
+
+=over
+
+=item new()
+
+Create a new importer source.
+
+Sources should override this method.
+
+=cut
+
+sub new {
+  my ($class, %opts) = @_;
+
+  my $importer = delete $opts{importer};
+  my $opts = delete $opts{opts};
+
+  return bless
+    {
+    }, $class;
+}
+
+=back
+
+=head1 SOURCE METHODS
+
+Sources should provide the following methods:
+
+=over
+
+=item each_row()
+
+  $source->each_row($importer, $filename);
+
+Called by the importer to parse the source.
+
+This should populate the storage used by get_column() and call 
+
+  $importer->row($source)
+
+for each row found.
+
+=item get_column()
+
+  $source->get_column($column_number)
+
+Return the data found in column C<$column_number> in the source row.
+
+=item rowid()
+
+Return a description of the current row.
+
+=back
+
+=cut
+
+1;
diff --git a/site/cgi-bin/modules/BSE/Importer/Source/XLS.pm b/site/cgi-bin/modules/BSE/Importer/Source/XLS.pm
new file mode 100644 (file)
index 0000000..7abc8f1
--- /dev/null
@@ -0,0 +1,95 @@
+package BSE::Importer::Source::XLS;
+use strict;
+use base 'BSE::Importer::Source::Base';
+use Spreadsheet::ParseExcel;
+
+our $VERSION = "1.001";
+
+=head1 NAME
+
+BSE::Importer::Source::XLS - import source for XLS files.
+
+=head1 SYNOPSIS
+
+   [import profile foo]
+   ; XLS is the default and can be ommitted
+   source=XLS
+   ; these are the defaults
+   sheet=1
+   skiprows=1
+
+=head1 DESCRIPTION
+
+Uses an Excel XLS file (not XLSX) as a data source.
+
+=head1 CONFIGURATION
+
+The following extra configuration can be set in the profile's
+configuration:
+
+=over
+
+=item *
+
+C<sheet> - the sheet number to import.  Default: 1.
+
+=item *
+
+C<skiprows> - the number of rows for skip at the top, eg. for column
+headings.  Default: 1.
+
+=back
+
+=cut
+
+sub new {
+  my ($class, %opts) = @_;
+
+  my $self = $class->SUPER::new(%opts);
+
+  my $importer = delete $opts{importer};
+  my $opts = delete $opts{opts};
+
+  $self->{sheet} = $importer->cfg_entry("sheet", 1);
+  $self->{skiprows} = $importer->cfg_entry('skiprows', 1);
+
+  return $self;
+}
+
+sub each_row {
+  my ($self, $importer, $filename) = @_;
+
+  my $parser = Spreadsheet::ParseExcel->new;
+  my $wb = $parser->Parse($filename)
+    or die "Could not parse $filename\n";
+  $self->{sheet} <= $wb->{SheetCount}
+    or die "No enough worksheets in input\n";
+  $self->{ws} = ($wb->worksheets)[$self->{sheet}-1]
+    or die "No worksheet found at $self->{sheet}\n";
+
+  my ($minrow, $maxrow) = $self->{ws}->RowRange;
+  for my $rownum ($self->{skiprows} ... $maxrow) {
+    $self->{rownum} = $rownum;
+    $importer->row($self);
+  }
+}
+
+sub get_column {
+  my ($self, $colnum) = @_;
+
+  my $cell = $self->{ws}->get_cell($self->{rownum}, $colnum-1);
+  if (defined $cell) {
+    return $cell->value;
+  }
+  else {
+    return '';
+  }
+}
+
+sub rowid {
+  my $self = shift;
+
+  return "Row " . ($self->{rownum}+1);
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/Importer/Target/Article.pm b/site/cgi-bin/modules/BSE/Importer/Target/Article.pm
new file mode 100644 (file)
index 0000000..578c37f
--- /dev/null
@@ -0,0 +1,444 @@
+package BSE::Importer::Target::Article;
+use strict;
+use base 'BSE::Importer::Target::Base';
+use BSE::API qw(bse_make_article bse_add_image bse_add_step_parent);
+use Articles;
+use Products;
+use OtherParents;
+
+our $VERSION = "1.002";
+
+=head1 NAME
+
+BSE::Importer::Target::Article - import target for articles.
+
+=head1 SYNOPSIS
+
+  [import profile foo]
+  ...
+  ; these are the defaults
+  codes=0
+  code_field=linkAlias
+  parent=-1
+  ignore_missing=1
+  reset_images=0
+  reset_steps=0
+
+  # done by the importer
+  my $target = BSE::Importer::Target::Article->new
+     (importer => $importer, opts => \%opts)
+  ...
+  $target->start($imp);
+  # for each row:
+  $target->row($imp, \%entry, \@parents);
+
+
+=head1 DESCRIPTION
+
+Provides a target for importing BSE articles.
+
+The import profile must provide a C<title> mapping.
+
+=head1 CONFIGURATION
+
+The following extra configuration can be set in the import profile:
+
+=over
+
+=item *
+
+C<codes> - set to true to use the configured C<code_field> to update
+existing articles rather than creating new articles.
+
+=item *
+
+C<code_field> - the field to use to identify existing articles.
+Default: C<linkAlias> for article imports.
+
+=item *
+
+C<parent> - the base of the tree of parent articles to create the
+parent tree under.
+
+=item *
+
+C<ignore_missing> - set to 0 to error on missing image files.
+Default: 1.
+
+=item *
+
+C<reset_images> - set to true to delete all images from an article
+before adding the imported images.
+
+=item *
+
+C<reset_steps> - set to true to delete all step parents from an
+article before adding the imported steps.
+
+=back
+
+=head1 SPECIAL FIELDS
+
+The following fields are used to import extra information into
+articles:
+
+=over
+
+=item *
+
+C<< imageI<index>_I<field> >> - used to import images,
+eg. C<image1_file> to specify the image file.  Note: images are not
+replaced unless C<reset_images> is set.  I<index> is a number from 1
+to 10, I<field> can be any of C<file>, C<alt>, C<name>, C<url>,
+C<storage>, with the C<file> entry being required.
+
+=item *
+
+C<< stepI<index> >> - specify step parents for the article.  This can
+either be the article id or the article link alias.
+
+=item *
+
+C<tags> - this is split on C</> to set the tags for the article.
+
+=back
+
+=head1 METHODS
+
+=over
+
+=item new()
+
+Create a new article import target.  Follows the protocol specified by
+L<BSE::Importer::Target::Base>.
+
+=cut
+
+sub new {
+  my ($class, %opts) = @_;
+
+  my $self = $class->SUPER::new(%opts);
+
+  my $importer = delete $opts{importer};
+
+  my $map = $importer->maps;
+  defined $map->{title}
+    or die "No title mapping found\n";
+
+  $self->{use_codes} = $importer->cfg_entry('codes', 0);
+  $self->{code_field} = $importer->cfg_entry("code_field", $self->default_code_field);
+
+  $self->{parent} = $importer->cfg_entry("parent", $self->default_parent);
+
+  if ($self->{use_codes} && !defined $map->{$self->{code_field}}) {
+    die "No product_code mapping found with 'codes' enabled\n";
+  }
+  $self->{ignore_missing} = $importer->cfg_entry("ignore_missing", 1);
+  $self->{reset_images} = $importer->cfg_entry("reset_images", 0);
+  $self->{reset_steps} = $importer->cfg_entry("reset_steps", 0);
+
+  return $self;
+}
+
+=item start()
+
+Start import processing.
+
+=cut
+
+sub start {
+  my ($self) = @_;
+
+  $self->{parent_cache} = {};
+  $self->{leaves} = [];
+  $self->{parents} = [];
+}
+
+=item row()
+
+Process a row of data.
+
+=cut
+
+sub row {
+  my ($self, $importer, $entry, $parents) = @_;
+
+  $self->xform_entry($importer, $entry);
+  
+  $entry->{parentid} = $self->_find_parent($importer, $self->{parent}, @$parents);
+  my $leaf;
+  if ($self->{use_codes}) {
+    my $leaf_id = $entry->{$self->{code_field}};
+    
+    $leaf = $self->find_leaf($leaf_id);
+  }
+  if ($leaf) {
+    @{$leaf}{keys %$entry} = values %$entry;
+    $leaf->save;
+    $importer->info("Updated $leaf->{id}: $entry->{title}");
+    if ($self->{reset_images}) {
+      $leaf->remove_images($importer->cfg);
+      $importer->info(" $leaf->{id}: Reset images");
+    }
+    if ($self->{reset_steps}) {
+      my @steps = OtherParents->getBy(childId => $leaf->{id});
+      for my $step (@steps) {
+       $step->remove;
+      }
+    }
+  }
+  else {
+    $leaf = $self->make_leaf
+      (
+       $importer, 
+       cfg => $importer->cfg,
+       %$entry
+      );
+    $importer->info("Added $leaf->{id}: $entry->{title}");
+  }
+  for my $image_index (1 .. 10) {
+    my $file = $entry->{"image${image_index}_file"};
+    $file
+      or next;
+    my $full_file = $importer->find_file($file);
+
+    unless ($full_file) {
+      $self->{ignore_missing}
+       and next;
+      die "File '$file' not found for image$image_index\n";
+    }
+
+    my %opts = ( file => $full_file );
+    for my $key (qw/alt name url storage/) {
+      my $fkey = "image${image_index}_$key";
+      $entry->{$fkey}
+       and $opts{$key} = $entry->{$fkey};
+    }
+    
+    my %errors;
+    my $im = bse_add_image($importer->cfg, $leaf, %opts, 
+                          errors => \%errors);
+    $im 
+      or die join(", ",map "$_: $errors{$_}", keys %errors), "\n";
+    $importer->info(" $leaf->{id}: Add image '$file'");
+  }
+  for my $step_index (1 .. 10) {
+    my $step_id = $entry->{"step$step_index"};
+    $step_id
+      or next;
+    my $step;
+    if ($step_id =~ /^\d+$/) {
+      $step = Articles->getByPkey($step_id);
+    }
+    else {
+      $step = Articles->getBy(linkAlias => $step_id);
+    }
+    $step
+      or die "Cannot find stepparent with id $step_id\n";
+
+    bse_add_step_parent($importer->cfg, child => $leaf, parent => $step);
+  }
+  $self->fill_leaf($importer, $leaf, %$entry);
+  push @{$self->{leaves}}, $leaf;
+}
+
+=item xform_entry()
+
+Called by row() to perform an extra data transformation needed.
+
+Currently this forces a non-blank, non-newline title, and defaults the
+values of C<summary>, C<description> and C<body> to the title.
+
+=cut
+
+sub xform_entry {
+  my ($self, $importer, $entry) = @_;
+
+  $entry->{title} =~ /\S/
+    or die "title blank\n";
+
+  $entry->{title} =~ /\n/
+    and die "Title may not contain newlines";
+  $entry->{summary}
+    or $entry->{summary} = $entry->{title};
+  $entry->{description}
+    or $entry->{description} = $entry->{title};
+  $entry->{body}
+    or $entry->{body} = $entry->{title};
+}
+
+=item children_of()
+
+Utility method to find the children of a given article.
+
+=cut
+
+sub children_of {
+  my ($self, $parent) = @_;
+
+  Articles->children($parent);
+}
+
+=item make_parent()
+
+Create a parent article.
+
+Overridden in the product importer to create catalogs.
+
+=cut
+
+sub make_parent {
+  my ($self, $importer, %entry) = @_;
+
+  return bse_make_article(%entry);
+}
+
+=item find_leaf()
+
+Find a leave article based on the supplied code.
+
+=cut
+
+sub find_leaf {
+  my ($self, $leaf_id) = @_;
+
+  $leaf_id =~ tr/A-Za-z0-9_/_/cds;
+
+  my ($leaf) = Articles->getBy($self->{code_field}, $leaf_id)
+    or return;
+
+  return $leaf;
+}
+
+=item make_leaf()
+
+Create an article based on the imported data.
+
+Overridden in the product importer to create products.
+
+=cut
+
+sub make_leaf {
+  my ($self, $importer, %entry) = @_;
+
+  return bse_make_article(%entry);
+}
+
+=item fill_leaf()
+
+Fill the article some more.
+
+Currently sets the tags.
+
+Overridden by the product target to set product options and tiered
+pricing.
+
+=cut
+
+sub fill_leaf {
+  my ($self, $importer, $leaf, %entry) = @_;
+
+  if ($entry{tags}) {
+    my @tags = split '/', $entry{tags};
+    my $error;
+    unless ($leaf->set_tags(\@tags, \$error)) {
+      die "Error setting tags: $error";
+    }
+  }
+
+  return 1;
+}
+
+=item _find_parent()
+
+Find a parent article.
+
+This method calls itself recursively to work down a tree of parents.
+
+=cut
+
+sub _find_parent {
+  my ($self, $importer, $parent, @parents) = @_;
+
+  @parents
+    or return $parent;
+  my $cache = $self->{parent_cache};
+  unless ($cache->{$parent}) {
+    my @kids = $self->children_of($parent);
+    $cache->{$parent} = \@kids;
+  }
+
+  my $title = shift @parents;
+  my ($cat) = grep lc $_->{title} eq lc $title, @{$cache->{$parent}};
+  unless ($cat) {
+    my %opts =
+      (
+       cfg => $importer->cfg,
+       parentid => $parent,
+       title => $title,
+       body => $title,
+      );
+    $self->{catalog_template}
+      and $opts{template} = $self->{catalog_template};
+    $cat = $self->make_parent($importer, %opts);
+    $importer->info("Add parent $cat->{id}: $title");
+    push @{$cache->{$parent}}, $cat;
+  }
+
+  unless ($self->{catseen}{$cat->{id}}) {
+    $self->{catseen}{$cat->{id}} = 1;
+    push @{$self->{parents}}, $cat;
+  }
+
+  return $self->_find_parent($importer, $cat->{id}, @parents);
+}
+
+=item default_parent()
+
+Return the default parent id.
+
+Overridden by the product target to return the shop id.
+
+=cut
+
+sub default_parent { -1 }
+
+=item default_code_field()
+
+Return the default code field.
+
+Overridden by the produuct target to return the C<product_code> field.
+
+=cut
+
+sub default_code_field { "linkAlias" }
+
+=item leaves()
+
+Return the leaf articles created or modified by the import run.
+
+=cut
+
+sub leaves {
+  return @{$_[0]{leaves}}
+}
+
+=item parents()
+
+Return the parent articles created or used by the import run.
+
+=cut
+
+sub parents {
+  return @{$_[0]{parents}}
+}
+
+1;
+
+=back
+
+=head1 AUTHOR
+
+Tony Cook <tony@develop-help.com>
+
+=cut
diff --git a/site/cgi-bin/modules/BSE/Importer/Target/Base.pm b/site/cgi-bin/modules/BSE/Importer/Target/Base.pm
new file mode 100644 (file)
index 0000000..01b11a2
--- /dev/null
@@ -0,0 +1,92 @@
+package BSE::Importer::Target::Base;
+use strict;
+
+our $VERSION = "1.001";
+
+=head1 NAME
+
+BSE::Importer::Target::Base - base class for importer targets
+
+=head1 SYNOPSIS
+
+  # done by BSE::Importer
+  my $target = $target_class->new(importer=>$imp, opts => \%opts);
+  ...
+  $target->start($imp);
+  # for each row:
+  $target->row($imp, \%entry, \@parents);
+
+=head1 DESCRIPTION
+
+BSE::Importer::Target::Base is the base class for import targets.
+Currently it only provides a base new() method, but may provide others
+in the future.
+
+This class has no specific configuration.
+
+=head1 METHODS
+
+=over
+
+=item new()
+
+Create a new target object.  Expects the following arguments:
+
+=over
+
+=item *
+
+C<importer> - the importer object
+
+=item *
+
+C<opts> - the options supplied to the importer object new() method.
+
+=back
+
+=cut
+
+sub new {
+  my ($class, %opts) = @_;
+
+  my $self = bless {}, $class;
+
+  return $self;
+}
+
+1;
+
+=back
+
+=head1 TARGET METHODS
+
+Target classes should provide the following methods:
+
+=over
+
+=item start()
+
+  $target->start($importer)
+
+Called before processing of rows from the source starts.  This can be
+used for per file initialization.
+
+=item row()
+
+  $target->row($importer, \%entry, \@parents)
+
+Called for each row of source data.
+
+C<%entry> contains the data loaded from the source, including any
+derived from C<set_> and C<xform_> configuration.
+
+C<@parents> contains the data loaded from the C<cat1> .. C<cat3>
+columns.
+
+=back
+
+=head1 AUTHOR
+
+Tony Cook <tony@develop-help.com>
+
+=cut
diff --git a/site/cgi-bin/modules/BSE/Importer/Target/Product.pm b/site/cgi-bin/modules/BSE/Importer/Target/Product.pm
new file mode 100644 (file)
index 0000000..0cae338
--- /dev/null
@@ -0,0 +1,301 @@
+package BSE::Importer::Target::Product;
+use strict;
+use base 'BSE::Importer::Target::Article';
+use BSE::API qw(bse_make_product bse_make_catalog bse_add_image);
+use Articles;
+use Products;
+use BSE::TB::ProductOptions;
+use BSE::TB::ProductOptionValues;
+use BSE::TB::PriceTiers;
+
+our $VERSION = "1.002";
+
+=head1 NAME
+
+BSE::Importer::Target::Product - import target for products
+
+=head1 SYNOPSIS
+
+  [import profile foo]
+  ...
+  ; these are the defaults
+  codes=0
+  code_field=product_code
+  parent=3
+  ignore_missing=1
+  reset_images=0
+  reset_steps=0
+  price_dollar=0
+  prodopt_value_sep=|
+  reset_prodopts=1
+
+  # done by the importer
+  my $target = BSE::Importer::Target::Product->new
+     (importer => $importer, opts => \%opts)
+  ...
+  $target->start($imp);
+  # for each row:
+  $target->row($imp, \%entry, \@parents);
+
+=head1 DESCRIPTION
+
+Provides a target for importing BSE products.
+
+The import profile must provide C<title> and C<retailPrice> mappings.
+
+=head1 CONFIGURATION
+
+This is in addition to the configuration in
+L<BSE::Importer::Target::Article/CONFIGURATION>.
+
+=over
+
+=item *
+
+C<code_field> - the default changes to C<product_code>
+
+=item *
+
+C<parent> - the default changes to the id of the shop article.
+
+=item *
+
+C<price_dollar> - if true, the C<retailPrice> field and tier prices
+are treated as dollar amounts rather than cents.  Default: 0.
+
+=item *
+
+C<prodopt_value_sep> - the separator between product options.
+Default: C<|>.
+
+=item *
+
+C<reset_prodopts> - if true, product options are reset when updating a
+product.  Default: 1.
+
+=back
+
+=head1 SPECIAL FIELDS
+
+In addition to those in L<BSE::Importer::Target::Article/SPECIAL
+FIELDS>, the following fields are used to import extra information
+into products:
+
+=over
+
+=item *
+
+C<< prodoptI<index>_name >> - define the name of a product option.
+C<index> can be from 1 to 10.
+
+=item *
+
+C<< prodoptI<index>_values >> - define the values for a product
+option, separated by the configured C<prodop_value_sep>.
+
+=item *
+
+C<< tier_proce_I<tier_id> >> - set the product price for the specified
+tier.
+
+=back
+
+=head1 METHODS
+
+=over
+
+=item new()
+
+Create a new article import target.  Follows the protocol specified by
+L<BSE::Importer::Target::Base>.
+
+=cut
+
+sub new {
+  my ($class, %opts) = @_;
+
+  my $self = $class->SUPER::new(%opts);
+
+  my $importer = delete $opts{importer};
+
+  $self->{price_dollar} = $importer->cfg_entry('price_dollar', 0);
+  $self->{product_template} = $importer->cfg_entry('product_template');
+  $self->{catalog_template} = $importer->cfg_entry('catalog_template');
+  $self->{prodopt_value_sep} = $importer->cfg_entry("prodopt_separator", "|");
+  $self->{reset_prodopts} = $importer->cfg_entry("reset_prodopts", 1);
+
+  my $map = $importer->maps;
+  defined $map->{retailPrice}
+    or die "No retailPrice mapping found\n";
+
+  $self->{price_tiers} = +{ map { $_->id => $_ } BSE::TB::PriceTiers->all };
+
+  return $self;
+}
+
+=item xform_entry()
+
+Called by row() to perform an extra data transformation needed.
+
+Currently this forces non-blank code fields if C<codes> is set,
+removes the dollar sign if any from the retail prices, transforms the
+retail price from dollars to cents if C<price_dollar> is configured
+and warns if no price is set.
+
+=cut
+
+sub xform_entry {
+  my ($self, $importer, $entry) = @_;
+
+  $self->SUPER::xform_entry($importer, $entry);
+
+  if ($self->{use_codes}) {
+    $entry->{product_code} =~ /\S/
+      or die "product_code blank with use_codes\n";
+  }
+  $entry->{retailPrice} =~ s/\$//; # in case
+
+  if ($entry->{retailPrice} =~ /\d/) {
+    $self->{price_dollar}
+      and $entry->{retailPrice} *= 100;
+  }
+  else {
+    $importer->warn("Warning: no price");
+    $entry->{retailPrice} = 0;
+  }
+}
+
+=item children_of()
+
+Returns catalogs that are a child of the specified article.
+
+sub children_of {
+  my ($self, $parent) = @_;
+
+  return grep $_->{generator} eq 'Generate::Catalog',
+    Articles->children($parent);
+}
+
+=item make_parent()
+
+Create a catalog.
+
+=cut
+
+sub make_parent {
+  my ($self, $importer, %entry) = @_;
+
+  return bse_make_catalog(%entry);
+}
+
+=item find_leaf()
+
+Find an existing product matching the code.
+
+=cut
+
+sub find_leaf {
+  my ($self, $leaf_id) = @_;
+
+  my ($leaf) = Products->getBy($self->{code_field}, $leaf_id)
+    or return;
+
+  if ($self->{reset_prodopts}) {
+    my @options = $leaf->db_options;
+    for my $option (@options) {
+      $option->remove;
+    }
+  }
+
+  return $leaf;
+}
+
+=item make_leaf()
+
+Make a new product.
+
+=cut
+
+sub make_leaf {
+  my ($self, $importer, %entry) = @_;
+
+  return bse_make_product(%entry);
+}
+
+=item fill_leaf()
+
+Fill in the product with the new data.
+
+=cut
+
+sub fill_leaf {
+  my ($self, $importer, $leaf, %entry) = @_;
+
+  my $ordering = time;
+  for my $opt_num (1 .. 5) {
+    my $name = $entry{"prodopt${opt_num}_name"};
+    my $values = $entry{"prodopt${opt_num}_values"};
+
+    defined $name && $name =~ /\S/ && $values =~ /\S/
+      or next;
+    my @values = split /\Q$self->{prodopt_value_sep}/, $values
+      or next;
+
+    my $option = BSE::TB::ProductOptions->make
+      (
+       product_id => $leaf->id,
+       name => $name,
+       display_order => $ordering++,
+      );
+
+    for my $value (@values) {
+      my $entry = BSE::TB::ProductOptionValues->make
+       (
+        product_option_id => $option->id,
+        value => $value,
+        display_order => $ordering++,
+       );
+    }
+  }
+
+  my %prices;
+  for my $tier_id (keys %{$self->{price_tiers}}) {
+    my $price = $entry{"tier_price_$tier_id"};
+    if (defined $price && $price =~ /\d/) {
+      $price =~ s/\$//; # in case
+      $price *= 100 if $self->{price_dollar};
+
+      $prices{$tier_id} = $price;
+    }
+  }
+
+  $leaf->set_prices(\%prices);
+
+  return $self->SUPER::fill_leaf($importer, $leaf, %entry);
+}
+
+=item default_parent()
+
+Overrides the default parent.
+
+=cut
+
+sub default_parent { 3 }
+
+=item default_code_field()
+
+Overrides the default code field.
+
+=cut
+
+sub default_code_field { "product_code" }
+
+1;
+
+=back
+
+=head1 AUTHOR
+
+Tony Cook <tony@develop-help.com>
+
+=cut