table of FTP targets for invoice spool upload, #17620
authorMark Wells <mark@freeside.biz>
Wed, 13 Jun 2012 23:18:49 +0000 (16:18 -0700)
committerMark Wells <mark@freeside.biz>
Wed, 13 Jun 2012 23:18:49 +0000 (16:18 -0700)
17 files changed:
FS/FS/Conf.pm
FS/FS/Cron/upload.pm
FS/FS/Mason.pm
FS/FS/Misc.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/cust_bill.pm
FS/FS/ftp_target.pm [new file with mode: 0644]
FS/FS/part_event/Action/cust_bill_send_csv_ftp.pm
FS/FS/part_event/Action/cust_bill_spool_csv.pm
FS/MANIFEST
FS/t/ftp_target.t [new file with mode: 0644]
httemplate/browse/ftp_target.html [new file with mode: 0644]
httemplate/edit/ftp_target.html [new file with mode: 0755]
httemplate/edit/process/ftp_target.html [new file with mode: 0644]
httemplate/elements/menu.html
httemplate/misc/delete-ftp_target.html [new file with mode: 0644]

index 151e31c..0314992 100644 (file)
@@ -13,6 +13,7 @@ use FS::payby;
 use FS::conf;
 use FS::Record qw(qsearch qsearchs);
 use FS::UID qw(dbh datasrc use_confcompat);
+use FS::Misc;
 use FS::Misc::Geo;
 
 $base_dir = '%%%FREESIDE_CONF%%%';
@@ -3041,7 +3042,7 @@ and customer address. Include units.',
     'section'     => 'invoicing',
     'description' => 'Enable FTP of raw invoice data - format.',
     'type'        => 'select',
-    'select_enum' => [ '', 'default', 'oneline', 'billco', ],
+    'options'     => [ FS::Misc::spool_formats() ],
   },
 
   {
@@ -3077,7 +3078,7 @@ and customer address. Include units.',
     'section'     => 'invoicing',
     'description' => 'Enable spooling of raw invoice data - format.',
     'type'        => 'select',
-    'select_enum' => [ '', 'default', 'oneline', 'billco', ],
+    'options'     => [ FS::Misc::spool_formats() ],
   },
 
   {
@@ -3087,14 +3088,33 @@ and customer address. Include units.',
     'type'        => 'checkbox',
   },
 
-    {
-    'key'         => 'cust_bill-ftp_spool',
-    'section'     => 'invoicing',
-    'description' => 'Enable FTP upload of the invoice spool during daily processing',
-    'type'        => 'checkbox',
+  {
+    'key'         => 'bridgestone-batch_counter',
+    'section'     => '',
+    'description' => 'Batch counter for spool files.  Increments every time a spool file is uploaded.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
+    'key'         => 'bridgestone-prefix',
+    'section'     => '',
+    'description' => 'Agent identifier for uploading to BABT printing service.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
+    'key'         => 'bridgestone-confirm_template',
+    'section'     => '',
+    'description' => 'Confirmation email template for uploading to BABT service.  Text::Template format, with variables "$zipfile" (name of the zipped file), "$seq" (sequence number), "$prefix" (user ID string), and "$rows" (number of records in the file).  Should include Subject: and To: headers, separated from the rest of the message by a blank line.',
+    # this could use a true message template, but it's hard to see how that
+    # would make the world a better place
+    'type'        => 'textarea',
+    'per_agent'   => 1,
   },
 
-{
+  {
     'key'         => 'svc_acct-usage_suspend',
     'section'     => 'billing',
     'description' => 'Suspends the package an account belongs to when svc_acct.seconds or a bytecount is decremented to 0 or below (accounts with an empty seconds and up|down|totalbytes value are ignored).  Typically used in conjunction with prepaid packages and freeside-sqlradius-radacctd.',
index c266797..51e0d68 100644 (file)
@@ -9,6 +9,8 @@ use FS::Record qw( qsearch qsearchs );
 use FS::Conf;
 use FS::queue;
 use FS::agent;
+use FS::Misc qw( send_email ); #for bridgestone
+use FS::ftp_target;
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Request::Common;
@@ -47,70 +49,50 @@ sub upload {
 
   my @agents = $opt{'a'} ? FS::agent->by_key($opt{'a'}) : qsearch('agent', {});
 
-  if ( $conf->exists('cust_bill-ftp_spool') ) {
-    my $url = $conf->config('cust_bill-ftpdir');
-    $url = "/$url" unless $url =~ m[^/];
-    $url = 'ftp://' . $conf->config('cust_bill-ftpserver') . $url;
-
-    my $format = $conf->config('cust_bill-ftpformat');
-    my $username = $conf->config('cust_bill-ftpusername');
-    my $password = $conf->config('cust_bill-ftppassword');
-
-    my %task = (
-      'date'      => $date,
-      'l'         => $opt{'l'},
-      'm'         => $opt{'m'},
-      'v'         => $opt{'v'},
-      'username'  => $username,
-      'password'  => $password,
-      'url'       => $url,
-      'format'    => $format,
-    );
-
-    if ( $conf->exists('cust_bill-spoolagent') ) {
-      # then push each agent's spool separately
-      foreach ( @agents ) {
-        push @tasks, { %task, 'agentnum' => $_->agentnum };
-      }
-    }
-    elsif ( $opt{'a'} ) {
-      warn "Per-agent processing, but cust_bill-spoolagent is not enabled.\nSkipped invoice upload.\n";
-    }
-    else {
-      push @tasks, \%task;
+  my %task = (
+    'date'      => $date,
+    'l'         => $opt{'l'},
+    'm'         => $opt{'m'},
+    'v'         => $opt{'v'},
+  );
+
+  my @agentnums = ('', map {$_->agentnum} @agents);
+
+  foreach my $target (qsearch('ftp_target', {})) {
+    # We don't know here if it's spooled on a per-agent basis or not.
+    # (It could even be both, via different events.)  So queue up an 
+    # upload for each agent, plus one with null agentnum, and we'll 
+    # upload as many files as we find.
+    foreach my $a (@agentnums) {
+      push @tasks, {
+        %task,
+        'agentnum'  => $a,
+        'targetnum' => $target->targetnum,
+        'handling'  => $target->handling,
+      };
     }
   }
 
-  else { #check each agent for billco upload settings
-
-    my %task = (
-      'date'      => $date,
-      'l'         => $opt{'l'},
-      'm'         => $opt{'m'},
-      'v'         => $opt{'v'},
-    );
-
-    foreach (@agents) {
-      my $agentnum = $_->agentnum;
-
-      if ( $conf->config( 'billco-username', $agentnum, 1 ) ) {
-        my $username = $conf->config('billco-username', $agentnum, 1);
-        my $password = $conf->config('billco-password', $agentnum, 1);
-        my $clicode  = $conf->config('billco-clicode',  $agentnum, 1);
-        my $url      = $conf->config('billco-url',      $agentnum);
-        push @tasks, {
-          %task,
-          'agentnum' => $agentnum,
-          'username' => $username,
-          'password' => $password,
-          'url'      => $url,
-          'clicode'  => $clicode,
-          'format'   => 'billco',
-        };
-      }
-    } # foreach @agents
-
-  } #!if cust_bill-ftp_spool
+  # deprecated billco method
+  foreach (@agents) {
+    my $agentnum = $_->agentnum;
+
+    if ( $conf->config( 'billco-username', $agentnum, 1 ) ) {
+      my $username = $conf->config('billco-username', $agentnum, 1);
+      my $password = $conf->config('billco-password', $agentnum, 1);
+      my $clicode  = $conf->config('billco-clicode',  $agentnum, 1);
+      my $url      = $conf->config('billco-url',      $agentnum);
+      push @tasks, {
+        %task,
+        'agentnum' => $agentnum,
+        'username' => $username,
+        'password' => $password,
+        'url'      => $url,
+        'clicode'  => $clicode,
+        'handling' => 'billco',
+      };
+    }
+  } # foreach @agents
 
   foreach (@tasks) {
 
@@ -146,14 +128,7 @@ sub spool_upload {
   my $conf = new FS::Conf;
   my $dir = '%%%FREESIDE_EXPORT%%%/export.'. $FS::UID::datasrc. '/cust_bill';
 
-  my $agentnum = $opt{agentnum} or die "no agentnum provided\n";
-  my $url      = $opt{url} or die "no url for agent $agentnum\n";
-  $url =~ s/^\s+//; $url =~ s/\s+$//;
-
-  my $username = $opt{username} or die "no username for agent $agentnum\n";
-  my $password = $opt{password} or die "no password for agent $agentnum\n";
-
-  die "no date provided\n" unless $opt{date};
+  my $date = $opt{date} or die "no date provided\n";
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -166,23 +141,34 @@ sub spool_upload {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $agent = qsearchs( 'agent', { agentnum => $agentnum } )
-    or die "no such agent: $agentnum";
-  $agent->select_for_update; #mutex 
+  my $agentnum = $opt{agentnum};
+  my $agent;
+  if ( $agentnum ) {
+    $agent = qsearchs( 'agent', { agentnum => $agentnum } )
+      or die "no such agent: $agentnum";
+    $agent->select_for_update; #mutex 
+  }
 
-  if ( $opt{'format'} eq 'billco' ) {
+  if ( $opt{'handling'} eq 'billco' ) {
 
-    my $zipfile  = "$dir/agentnum$agentnum-$opt{date}.zip";
+    my $file = "agentnum$agentnum";
+    my $zipfile  = "$dir/$file-$date.zip";
 
-    unless ( -f "$dir/agentnum$agentnum-header.csv" ||
-             -f "$dir/agentnum$agentnum-detail.csv" )
+    unless ( -f "$dir/$file-header.csv" ||
+             -f "$dir/$file-detail.csv" )
     {
-      warn "$me neither $dir/agentnum$agentnum-header.csv nor ".
-           "$dir/agentnum$agentnum-detail.csv found\n" if $DEBUG;
+      warn "$me neither $dir/$file-header.csv nor ".
+           "$dir/$file-detail.csv found\n" if $DEBUG > 1;
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
       return;
     }
 
+    my $url      = $opt{url} or die "no url for agent $agentnum\n";
+    $url =~ s/^\s+//; $url =~ s/\s+$//;
+
+    my $username = $opt{username} or die "no username for agent $agentnum\n";
+    my $password = $opt{password} or die "no password for agent $agentnum\n";
+
     # a better way?
     if ($opt{m}) {
       my $sql = "SELECT count(*) FROM queue LEFT JOIN cust_main USING(custnum) ".
@@ -197,18 +183,18 @@ sub spool_upload {
     }
 
     foreach ( qw ( header detail ) ) {
-      rename "$dir/agentnum$agentnum-$_.csv",
-             "$dir/agentnum$agentnum-$opt{date}-$_.csv";
+      rename "$dir/$file-$_.csv",
+             "$dir/$file-$date-$_.csv";
     }
 
     my $command = "cd $dir; zip $zipfile ".
-                  "agentnum$agentnum-$opt{date}-header.csv ".
-                  "agentnum$agentnum-$opt{date}-detail.csv";
+                  "$file-$date-header.csv ".
+                  "$file-$date-detail.csv";
 
     system($command) and die "$command failed\n";
 
-    unlink "agentnum$agentnum-$opt{date}-header.csv",
-           "agentnum$agentnum-$opt{date}-detail.csv";
+    unlink "$file-$date-header.csv",
+           "$file-$date-detail.csv";
 
     if ( $url =~ /^http/i ) {
 
@@ -251,38 +237,132 @@ sub spool_upload {
       die "unknown scheme in URL $url\n";
     }
 
-  } else { #$opt{format} ne 'billco'
+  }
+  else { #not billco
+
+    my $targetnum = $opt{targetnum};
+    my $ftp_target = FS::ftp_target->by_key($targetnum)
+      or die "FTP target $targetnum not found\n";
+
+    $dir .= "/target$targetnum";
+    chdir($dir);
+
+    my $file  = $agentnum ? "agentnum$agentnum" : 'spool'; #.csv
 
-    my $date = $opt{date};
-    my $file = $opt{agentnum} ? "agentnum$opt{agentnum}" : 'spool'; #.csv
     unless ( -f "$dir/$file.csv" ) {
-      warn "$me $dir/$file.csv not found\n" if $DEBUG;
+      warn "$me $dir/$file.csv not found\n" if $DEBUG > 1;
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
       return;
     }
+
     rename "$dir/$file.csv", "$dir/$file-$date.csv";
 
-    #ftp only for now
-    if ( $url =~ m{^ftp://([\w\.]+)(/.*)$}i ) {
+    if ( $opt{'handling'} eq 'bridgestone' ) {
 
-      my ($hostname, $path) = ($1, $2);
-      my $ftp = new Net::FTP ($hostname)
-        or die "can't connect to $hostname: $@\n";
-      $ftp->login($username, $password)
-        or die "can't login to $hostname: ".$ftp->message."\n";
-      unless ( $ftp->cwd($path) ) {
-        my $msg = "can't cd $path on $hostname: ".$ftp->message."\n";
-        ( $path eq '/' ) ? warn $msg : die $msg;
+      my $prefix = $conf->config('bridgestone-prefix', $agentnum);
+      unless ( $prefix ) {
+        warn "$me agent $agentnum has no bridgestone-prefix, skipped\n";
+        $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+        return;
       }
-      chdir($dir);
-      $ftp->put("$file-$date.csv")
-        or die "can't put $file-$date.csv: ".$ftp->message."\n";
-      $ftp->quit;
 
-    } else {
-      die "malformed FTP URL $url\n";
+      my $seq = $conf->config('bridgestone-batch_counter', $agentnum) || 1;
+
+      # extract zip code
+      join(' ',$conf->config('company_address', $agentnum)) =~ 
+        /(\d{5}(\-\d{4})?)\s*$/;
+      my $ourzip = $1 || ''; #could be an explicit option if really needed
+      $ourzip  =~ s/\D//;
+      my $newfile = sprintf('%s_%s_%0.6d.dat', 
+                            $prefix,
+                            time2str('%Y%m%d', time),
+                            $seq);
+      warn "copying spool to $newfile\n" if $DEBUG;
+
+      my ($in, $out);
+      open $in, '<', "$dir/$file-$date.csv" 
+        or die "unable to read $file-$date.csv\n";
+      open $out, '>', "$dir/$newfile" or die "unable to write $newfile\n";
+      #header--not sure how much of this generalizes at all
+      my $head = sprintf(
+        "%-6s%-4s%-27s%-6s%0.6d%-5s%-9s%-9s%-7s%0.8d%-7s%0.6d\n",
+        ' COMP:', 'VISP', '', ',SEQ#:', $seq, ',ZIP:', $ourzip, ',VERS:1.1',
+        ',RUNDT:', time2str('%m%d%Y', $^T),
+        ',RUNTM:', time2str('%H%M%S', $^T),
+      );
+      warn "HEADER: $head" if $DEBUG;
+      print $out $head;
+
+      my $rows = 0;
+      while( <$in> ) {
+        print $out $_;
+        $rows++;
+      }
+
+      #trailer
+      my $trail = sprintf(
+        "%-6s%-4s%-27s%-6s%0.6d%-7s%0.9d%-9s%0.9d\n",
+        ' COMP:', 'VISP', '', ',SEQ:', $seq,
+        ',LINES:', $rows+2, ',LETTERS:', $rows,
+      );
+      warn "TRAILER: $trail" if $DEBUG;
+      print $out $trail;
+
+      close $in;
+      close $out;
+
+      my $zipfile = sprintf('%s_%0.6d.zip', $prefix, $seq);
+      my $command = "cd $dir; zip $zipfile $newfile";
+      warn "compressing to $zipfile\n$command\n" if $DEBUG;
+      system($command) and die "$command failed\n";
+
+      my $connection = $ftp_target->connect; # dies on error
+      $connection->put($zipfile);
+
+      my $template = join("\n",$conf->config('bridgestone-confirm_template'));
+      if ( $template ) {
+        my $tmpl_obj = Text::Template->new(
+          TYPE => 'STRING', SOURCE => $template
+        );
+        my $content = $tmpl_obj->fill_in( HASH =>
+          {
+            zipfile => $zipfile,
+            prefix  => $prefix,
+            seq     => $seq,
+            rows    => $rows,
+          }
+        );
+        my ($head, $body) = split("\n\n", $content, 2);
+        $head =~ /^subject:\s*(.*)$/im;
+        my $subject = $1;
+
+        $head =~ /^to:\s*(.*)$/im;
+        my $to = $1;
+
+        send_email(
+          to      => $to,
+          from    => $conf->config('invoice_from', $agentnum),
+          subject => $subject,
+          body    => $body,
+        );
+      } else { #!$template
+        warn "$me agent $agentnum has no bridgestone-confirm_template, no email sent\n";
+      }
+
+      $seq++;
+      warn "setting batch counter to $seq\n" if $DEBUG;
+      $conf->set('bridgestone-batch_counter', $seq, $agentnum);
+
+    } else { # not bridgestone
+
+      # this is the usual case
+
+      my $connection = $ftp_target->connect; # dies on error
+      $connection->put("$file-$date.csv");
+
     }
-  } #opt{format}
+
+  } #opt{handling}
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
index b0f20ec..6c4a1b8 100644 (file)
@@ -308,6 +308,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::access_groupsales;
   use FS::contact_class;
   use FS::part_svc_class;
+  use FS::ftp_target;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index 297e39f..2be9ec2 100644 (file)
@@ -913,6 +913,16 @@ sub ocr_image {
   @lines;
 }
 
+=item spool_formats
+  
+Returns a list of the invoice spool formats.
+
+=cut
+
+sub spool_formats {
+  qw(default oneline billco bridgestone)
+}
+
 =back
 
 =head1 BUGS
index a93a10a..0ac269f 100644 (file)
@@ -2563,6 +2563,22 @@ sub ut_enumn {
     : '';
 }
 
+=item ut_flag COLUMN
+
+Check/untaint a column if it contains either an empty string or 'Y'.  This
+is the standard form for boolean flags in Freeside.
+
+=cut
+
+sub ut_flag {
+  my( $self, $field ) = @_;
+  my $value = uc($self->getfield($field));
+  if ( $value eq '' or $value eq 'Y' ) {
+    $self->setfield($field, $value);
+    return '';
+  }
+  return "Illegal (flag) field $field: $value";
+}
 
 =item ut_foreign_key COLUMN FOREIGN_TABLE FOREIGN_COLUMN
 
index 5476589..a90c73a 100644 (file)
@@ -3680,6 +3680,23 @@ sub tables_hashref {
       'index' => [ [ 'upgrade' ] ],
     },
 
+    'ftp_target' => {
+      'columns' => [
+        'targetnum', 'serial', '', '', '', '',
+        'agentnum', 'int', 'NULL', '', '', '',
+        'hostname', 'varchar', '', $char_d, '', '',
+        'port', 'int', '', '', '', '',
+        'username', 'varchar', '', $char_d, '', '',
+        'password', 'varchar', '', $char_d, '', '',
+        'path', 'varchar', '', $char_d, '', '',
+        'secure', 'char', 'NULL', 1, '', '',
+        'handling', 'varchar', 'NULL', $char_d, '', '',
+      ],
+      'primary_key' => 'targetnum',
+      'unique' => [ [ 'targetnum' ] ],
+      'index' => [],
+    },
+
     %{ tables_hashref_torrus() },
 
     # tables of ours for doing torrus virtual port combining
index 5b41d4b..d94ab20 100644 (file)
@@ -1753,13 +1753,21 @@ Options are:
 
 =over 4
 
-=item format - 'default' or 'billco'
+=item format - any of FS::Misc::spool_formats
 
-=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
+customer has the corresponding invoice destinations set (see
+L<FS::cust_main_invoice>).
 
-=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+=item agent_spools - if set to a true value, will spool to per-agent files
+rather than a single global file
 
-=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+=item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
+append to that spool.  L<FS::Cron::upload> will then send the spool file to
+that destination.
+
+=item balanceover - if set, only spools the invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
 
 =back
 
@@ -1787,11 +1795,23 @@ sub spool_csv {
 
   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
 
-  my $file =
-    "$spooldir/".
-    ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-    ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
-    '.csv';
+  my $file;
+  if ( $opt{'agent_spools'} ) {
+    $file = 'agentnum'.$cust_main->agentnum;
+  } else {
+    $file = 'spool';
+  }
+
+  if ( $opt{'ftp_targetnum'} ) {
+    $spooldir .= '/target'.$opt{'ftp_targetnum'};
+    mkdir $spooldir, 0700 unless -d $spooldir;
+  } # otherwise it just goes into export.xxx/cust_bill
+
+  if ( lc($opt{'format'}) eq 'billco' ) {
+    $file .= '-header';
+  }
+
+  $file = "$spooldir/$file.csv";
   
   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
 
@@ -1806,10 +1826,7 @@ sub spool_csv {
     flock(CSV, LOCK_UN);
     close CSV;
 
-    $file =
-      "$spooldir/".
-      ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-      '-detail.csv';
+    $file =~ s/-header.csv$/-detail.csv/;
 
     open(CSV,">>$file") or die "can't open $file: $!";
     flock(CSV, LOCK_EX);
@@ -1831,7 +1848,7 @@ Returns CSV data for this invoice.
 
 Options are:
 
-format - 'default' or 'billco'
+format - 'default', 'billco', 'oneline', 'bridgestone'
 
 Returns a list consisting of two scalars.  The first is a single line of CSV
 header information for this invoice.  The second is one or more lines of CSV
@@ -1840,7 +1857,8 @@ detail information for this invoice.
 If I<format> is not specified or "default", the fields of the CSV file are as
 follows:
 
-record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+record_type, invnum, custnum, _date, charged, first, last, company, address1, 
+address2, city, state, zip, country, pkg, setup, recur, sdate, edate
 
 =over 4
 
@@ -1945,6 +1963,26 @@ If I<format> is "billco", the fields of the detail CSV file are as follows:
   9     | Grouping Code              | GROUP     | CHAR |     2
   10    | User Defined               | ACCT CODE | CHAR |    15
 
+If format is 'oneline', there is no detail file.  Each invoice has a 
+header line only, with the fields:
+
+Agent number, agent name, customer number, first name, last name, address
+line 1, address line 2, city, state, zip, invoice date, invoice number,
+amount charged, amount due,
+
+and then, for each line item, three columns containing the package number,
+description, and amount.
+
+If format is 'bridgestone', there is no detail file.  Each invoice has a 
+header line with the following fields in a fixed-width format:
+
+Customer number (in display format), date, name (first last), company,
+address 1, address 2, city, state, zip.
+
+This is a mailing list format, and has no per-invoice fields.  To avoid
+sending redundant notices, the spooling event should have a "once" or 
+"once_percust_every" condition.
+
 =cut
 
 sub print_csv {
@@ -2041,6 +2079,31 @@ sub print_csv {
       @items,
     );
 
+  } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
+
+    # bypass the CSV stuff and just return this
+    my $longdate = time2str('%B %d, %Y', time); #current time, right?
+    my $zip = $cust_main->zip;
+    $zip =~ s/\D//;
+    my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
+      || '';
+    return (
+      sprintf(
+        "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
+        $prefix,
+        $cust_main->display_custnum,
+        $longdate,
+        uc(substr($cust_main->contact_firstlast,0,30)),
+        uc(substr($cust_main->company          ,0,30)),
+        uc(substr($cust_main->address1         ,0,30)),
+        uc(substr($cust_main->address2         ,0,30)),
+        uc(substr($cust_main->city             ,0,20)),
+        uc($cust_main->state),
+        $zip
+      ),
+      '' #detail
+      );
+
   } else {
   
     $csv->combine(
@@ -5442,6 +5505,7 @@ sub process_re_X {
 }
 
 sub re_X {
+  # spool_invoice ftp_invoice fax_invoice print_invoice
   my($method, $job, %param ) = @_;
   if ( $DEBUG ) {
     warn "re_X $method for job $job with param:\n".
diff --git a/FS/FS/ftp_target.pm b/FS/FS/ftp_target.pm
new file mode 100644 (file)
index 0000000..bf9fc89
--- /dev/null
@@ -0,0 +1,194 @@
+package FS::ftp_target;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use vars qw($me $DEBUG);
+
+$DEBUG = 0;
+
+=head1 NAME
+
+FS::ftp_target - Object methods for ftp_target records
+
+=head1 SYNOPSIS
+
+  use FS::ftp_target;
+
+  $record = new FS::ftp_target \%hash;
+  $record = new FS::ftp_target { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::ftp_target object represents an account on a remote FTP or SFTP 
+server for transferring files.  FS::ftp_target inherits from FS::Record.
+
+=over 4
+
+=item targetnum - primary key
+
+=item agentnum - L<FS::agent> foreign key; can be null
+
+=item hostname - the DNS name of the FTP site
+
+=item username - username
+
+=item password - password
+
+=item path - the working directory to change to upon connecting
+
+=item secure - a flag ('Y' or null) for whether to use SFTP
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+sub table { 'ftp_target'; }
+
+=item new HASHREF
+
+Creates a new FTP target.  To add it to the database, see L<"insert">.
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  if ( !$self->get('port') ) {
+    if ( $self->secure ) {
+      $self->set('port', 22);
+    } else {
+      $self->set('port', 21);
+    }
+  }
+
+  my $error = 
+    $self->ut_numbern('targetnum')
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_text('hostname')
+    || $self->ut_text('username')
+    || $self->ut_text('password')
+    || $self->ut_number('port')
+    || $self->ut_text('path')
+    || $self->ut_flag('secure')
+    || $self->ut_enum('handling', [ $self->handling_types ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item connect
+
+Creates a Net::FTP or Net::SFTP::Foreign object (according to the setting
+of the 'secure' flag), connects to 'hostname', attempts to log in with 
+'username' and 'password', and changes the working directory to 'path'.
+On success, returns the object.  On failure, dies with an error message.
+
+=cut
+
+sub connect {
+  my $self = shift;
+  if ( $self->secure ) {
+    eval "use Net::SFTP::Foreign;";
+    die $@ if $@;
+    my %args = (
+      port      => $self->port,
+      user      => $self->username,
+      password  => $self->password,
+      more      => ($DEBUG ? '-v' : ''),
+      timeout   => 30,
+      autodie   => 1, #we're doing this anyway
+    );
+    my $sftp = Net::SFTP::Foreign->new($self->hostname, %args);
+    $sftp->setcwd($self->path);
+    return $sftp;
+  }
+  else {
+    eval "use Net::FTP;";
+    die $@ if $@;
+    my %args = ( 
+      Debug   => $DEBUG,
+      Port    => $self->port,
+      Passive => 1,# optional?
+    );
+    my $ftp = Net::FTP->new($self->hostname, %args)
+      or die "connect to ".$self->hostname." failed: $@";
+    $ftp->login($self->username, $self->password)
+      or die "login to ".$self->username.'@'.$self->hostname." failed: $@";
+    $ftp->binary; #optional?
+    $ftp->cwd($self->path)
+      or ($self->path eq '/')
+      or die "cwd to ".$self->hostname.'/'.$self->path." failed: $@";
+
+    return $ftp;
+  }
+}
+
+=item label
+
+Returns a descriptive label for this target.
+
+=cut
+
+sub label {
+  my $self = shift;
+  $self->targetnum . ': ' . $self->username . '@' . $self->hostname;
+}
+
+=item handling_types
+
+Returns a list of values for the "handling" field, corresponding to the 
+known ways to preprocess a file before uploading.  Currently those are 
+implemented somewhat crudely in L<FS::Cron::upload>.
+
+=cut
+
+sub handling_types {
+  '',
+  #'billco', #not implemented this way yet
+  'bridgestone',
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 71bbaa8..8d2e36c 100644 (file)
@@ -2,6 +2,7 @@ package FS::part_event::Action::cust_bill_send_csv_ftp;
 
 use strict;
 use base qw( FS::part_event::Action );
+use FS::Misc;
 
 sub description { 'Upload CSV invoice data to an FTP server'; }
 
@@ -15,11 +16,7 @@ sub option_fields {
   (
     'ftpformat'   => { label   => 'Format',
                        type    =>'select',
-                       options => ['default', 'billco', 'oneline'],
-                       option_labels => { 'default' => 'Default',
-                                          'billco'  => 'Billco',
-                                          'oneline' => 'One line',
-                                        },
+                       options => [ FS::Misc::spool_formats() ],
                      },
     'ftpserver'   => 'FTP server',
     'ftpusername' => 'FTP username',
index 1504a4f..24c26ff 100644 (file)
@@ -2,6 +2,7 @@ package FS::part_event::Action::cust_bill_spool_csv;
 
 use strict;
 use base qw( FS::part_event::Action );
+use FS::Misc;
 
 sub description { 'Spool CSV invoice data'; }
 
@@ -15,11 +16,7 @@ sub option_fields {
   (
     'spoolformat'       => { label   => 'Format',
                              type    => 'select',
-                             options => ['default', 'billco', 'oneline'],
-                             option_labels => { 'default' => 'Default',
-                                                'billco'  => 'Billco',
-                                                'oneline' => 'One line',
-                                              },
+                             options => [ FS::Misc::spool_formats() ],
                            },
     'spoolbalanceover'  => { label =>
                                'If balance (this invoice and previous) over',
@@ -29,6 +26,13 @@ sub option_fields {
                              type  => 'checkbox',
                              value => '1',
                            },
+    'ftp_targetnum'     => { label    => 'Upload spool to FTP target',
+                             type     => 'select-table',
+                             table    => 'ftp_target',
+                             name_col => 'label',
+                             empty_label => '(do not upload)',
+                             order_by => 'targetnum',
+                           },
   );
 }
 
@@ -44,6 +48,7 @@ sub do_action {
     'format'       => $self->option('spoolformat'),
     'balanceover'  => $self->option('spoolbalanceover'),
     'agent_spools' => $self->option('spoolagent_spools'),
+    'ftp_targetnum'=> $self->option('ftp_targetnum'),
   );
 }
 
index 8bb4eee..030e69b 100644 (file)
@@ -640,3 +640,5 @@ FS/access_groupsales.pm
 t/access_groupsales.t
 FS/part_svc_class.pm
 t/part_svc_class.t
+FS/ftp_target.pm
+t/ftp_target.t
diff --git a/FS/t/ftp_target.t b/FS/t/ftp_target.t
new file mode 100644 (file)
index 0000000..1a59281
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::ftp_target;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/browse/ftp_target.html b/httemplate/browse/ftp_target.html
new file mode 100644 (file)
index 0000000..4a57820
--- /dev/null
@@ -0,0 +1,56 @@
+<& elements/browse.html,
+  'title'       => 'FTP targets',
+  'menubar'     => [ 'Add a target' => $p.'edit/ftp_target.html', ],
+  'name'        => 'FTP targets',
+  'query'       => { 'table'   => 'ftp_target',
+                     'hashref' => {},
+                   },
+  'count_query' => $count_query,
+  'header'      => [ '#',
+                     'Server',
+                     'Username',
+                     'Password',
+                     'Path',
+                     'Protocol',
+                     '', #handling
+                   ],
+  'fields'      => [ 'targetnum',
+                     'hostname',
+                     'username',
+                     'password',
+                     'path',
+                     sub { 
+                       my $ftp_target = shift;
+                       my $label;
+                       if ($ftp_target->secure) {
+                         $label = 'SFTP';
+                         $label .= ' (port '.$ftp_target->port.')'
+                           if $ftp_target->port != 22;
+                       }
+                       else {
+                         $label = 'FTP';
+                         $label .= ' (port '.$ftp_target->port.')'
+                           if $ftp_target->port != 21;
+                       }
+                       $label;
+                     },
+                     'handling',
+                   ],
+  'links'       => [ $link, $link ],
+&>
+</TABLE>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $count_query = 'SELECT COUNT(*) FROM ftp_target';
+
+</%once>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $link = [ $p.'edit/ftp_target.html?', 'targetnum' ];
+</%init>
diff --git a/httemplate/edit/ftp_target.html b/httemplate/edit/ftp_target.html
new file mode 100755 (executable)
index 0000000..aebf9aa
--- /dev/null
@@ -0,0 +1,46 @@
+<& elements/edit.html,
+  'post_url'    => popurl(1).'process/ftp_target.html',
+  'name'        => 'FTP target',
+  'table'       => 'ftp_target',
+  'viewall_url' => "${p}browse/ftp_target.html",
+  'labels'      => { targetnum => 'Target',
+                     hostname  => 'Server',
+                     username  => 'Username',
+                     password  => 'Password',
+                     path      => 'Directory',
+                     port      => 'Port',
+                     secure    => 'Use SFTP',
+                     handling  => 'Special handling',
+                   },
+  'fields'      => [
+                     { field => 'hostname', size => 40 },
+                     { field => 'port', size => 8 },
+                     { field => 'secure', type => 'checkbox', value => 'Y' },
+                     'username',
+                     'password',
+                     { field => 'path', size => 40 },
+                     { field => 'handling', 
+                       type => 'select',
+                       options => [ FS::ftp_target->handling_types ],
+                     },
+                   ],
+  'menubar'     => \@menubar,
+  'edit_callback' => $edit_callback,
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Configuration');
+
+my @menubar = ('View all FTP targets' => $p.'browse/ftp_target.html');
+my $edit_callback = sub {
+  my ($cgi, $object) = @_;
+  if ( $object->targetnum ) {
+    push @menubar, 'Delete this target', 
+                   $p.'misc/delete-ftp_target.html?'.$object->targetnum;
+  }
+};
+
+</%init>
diff --git a/httemplate/edit/process/ftp_target.html b/httemplate/edit/process/ftp_target.html
new file mode 100644 (file)
index 0000000..35f56c4
--- /dev/null
@@ -0,0 +1,12 @@
+<& elements/process.html,
+           'table'            => 'ftp_target',
+           'viewall_dir'      => 'browse',
+           'agent_null'       => 1,
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Configuration');
+
+</%init>
index cf79af9..06f7d59 100644 (file)
@@ -579,6 +579,9 @@ $config_misc{'Inventory classes and inventory'} = [ $fsurl.'browse/inventory_cla
   || $curuser->access_right('Edit global inventory')
   || $curuser->access_right('Configuration');
 
+$config_misc{'FTP targets'} = [ $fsurl.'browse/ftp_target.html', 'FTP servers for billing and payment processing' ]
+  if $curuser->access_right('Configuration');
+
 tie my %config_menu, 'Tie::IxHash';
 if ( $curuser->access_right('Configuration' ) ) {
   %config_menu = (
diff --git a/httemplate/misc/delete-ftp_target.html b/httemplate/misc/delete-ftp_target.html
new file mode 100644 (file)
index 0000000..c8bd297
--- /dev/null
@@ -0,0 +1,18 @@
+% if ( $error ) {
+%   errorpage($error);
+% } else {
+<% $cgi->redirect("${p}browse/ftp_target.html") %>
+% }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my($query) = $cgi->keywords;
+$query =~ /^(\d+)$/ || die "Illegal targetnum";
+my $targetnum = $1;
+
+my $target = qsearchs('ftp_target',{'targetnum'=>$targetnum});
+my $error = $target->delete;
+
+</%init>