wireless broadband service import, RT#38986
[freeside.git] / FS / FS / cust_main / Import.pm
1 package FS::cust_main::Import;
2
3 use strict;
4 use vars qw( $DEBUG $conf );
5 use Storable qw(thaw);
6 use Data::Dumper;
7 use MIME::Base64;
8 use File::Slurp qw( slurp );
9 use FS::Misc::DateTime qw( parse_datetime );
10 use FS::UID qw( dbh );
11 use FS::Record qw( qsearchs );
12 use FS::cust_main;
13 use FS::svc_acct;
14 use FS::svc_broadband;
15 use FS::svc_external;
16 use FS::svc_phone;
17 use FS::svc_hardware;
18 use FS::part_referral;
19
20 $DEBUG = 0;
21
22 install_callback FS::UID sub {
23   $conf = new FS::Conf;
24 };
25
26 my %is_location = map { $_ => 1 } FS::cust_main::Location->location_fields;
27
28 =head1 NAME
29
30 FS::cust_main::Import - Batch customer importing
31
32 =head1 SYNOPSIS
33
34   use FS::cust_main::Import;
35
36   #import
37   FS::cust_main::Import::batch_import( {
38     file      => $file,      #filename
39     type      => $type,      #csv or xls
40     format    => $format,    #extended, extended-plus_company, svc_external,
41                              #extended-plus_company_and_options
42                              #extended-plus_options, or svc_external_svc_phone
43     agentnum  => $agentnum,
44     refnum    => $refnum,
45     pkgpart   => $pkgpart,
46     job       => $job,       #optional job queue job, for progressbar updates
47     custbatch => $custbatch, #optional batch unique identifier
48   } );
49   die $error if $error;
50
51   #ajax helper
52   use FS::UI::Web::JSRPC;
53   my $server =
54     new FS::UI::Web::JSRPC 'FS::cust_main::Import::process_batch_import', $cgi;
55   print $server->process;
56
57 =head1 DESCRIPTION
58
59 Batch customer importing.
60
61 =head1 SUBROUTINES
62
63 =item process_batch_import
64
65 Load a batch import as a queued JSRPC job
66
67 =cut
68
69 sub process_batch_import {
70   my $job = shift;
71
72   my $param = thaw(decode_base64(shift));
73   warn Dumper($param) if $DEBUG;
74   
75   my $files = $param->{'uploaded_files'}
76     or die "No files provided.\n";
77
78   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
79
80   my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
81   my $file = $dir. $files{'file'};
82
83   my $type;
84   if ( $file =~ /\.(\w+)$/i ) {
85     $type = lc($1);
86   } else {
87     #or error out???
88     warn "can't parse file type from filename $file; defaulting to CSV";
89     $type = 'csv';
90   }
91
92   my $error =
93     FS::cust_main::Import::batch_import( {
94       job       => $job,
95       file      => $file,
96       type      => $type,
97       custbatch => $param->{custbatch},
98       agentnum  => $param->{'agentnum'},
99       refnum    => $param->{'refnum'},
100       pkgpart   => $param->{'pkgpart'},
101       #'fields'  => [qw( cust_pkg.setup dayphone first last address1 address2
102       #                 city state zip comments                          )],
103       'format'  => $param->{'format'},
104     } );
105
106   unlink $file;
107
108   die "$error\n" if $error;
109
110 }
111
112 =item batch_import
113
114 =cut
115
116
117 #some false laziness w/cdr.pm now
118 sub batch_import {
119   my $param = shift;
120
121   my $job       = $param->{job};
122
123   my $filename  = $param->{file};
124   my $type      = $param->{type} || 'csv';
125
126   my $custbatch = $param->{custbatch};
127
128   my $agentnum  = $param->{agentnum};
129   my $refnum    = $param->{refnum};
130   my $pkgpart   = $param->{pkgpart};
131
132   my $format    = $param->{'format'};
133
134   my @fields;
135   my $payby;
136   if ( $format eq 'simple' ) {
137     @fields = qw( cust_pkg.setup dayphone first last
138                   address1 address2 city state zip comments );
139     $payby = 'BILL';
140   } elsif ( $format eq 'extended' ) {
141     @fields = qw( agent_custid refnum
142                   last first address1 address2 city state zip country
143                   daytime night
144                   ship_last ship_first ship_address1 ship_address2
145                   ship_city ship_state ship_zip ship_country
146                   payinfo paycvv paydate
147                   invoicing_list
148                   cust_pkg.pkgpart
149                   svc_acct.username svc_acct._password 
150                 );
151     $payby = 'BILL';
152  } elsif ( $format eq 'extended-plus_options' ) {
153     @fields = qw( agent_custid refnum
154                   last first address1 address2 city state zip country
155                   daytime night
156                   ship_last ship_first ship_address1 ship_address2
157                   ship_city ship_state ship_zip ship_country
158                   payinfo paycvv paydate
159                   invoicing_list
160                   cust_pkg.pkgpart
161                   svc_acct.username svc_acct._password 
162                   customer_options
163                 );
164     $payby = 'BILL';
165  } elsif ( $format eq 'extended-plus_company' ) {
166     @fields = qw( agent_custid refnum
167                   last first company address1 address2 city state zip country
168                   daytime night
169                   ship_last ship_first ship_company ship_address1 ship_address2
170                   ship_city ship_state ship_zip ship_country
171                   payinfo paycvv paydate
172                   invoicing_list
173                   cust_pkg.pkgpart
174                   svc_acct.username svc_acct._password 
175                 );
176     $payby = 'BILL';
177  } elsif ( $format eq 'extended-plus_company_and_options' ) {
178     @fields = qw( agent_custid refnum
179                   last first company address1 address2 city state zip country
180                   daytime night
181                   ship_last ship_first ship_company ship_address1 ship_address2
182                   ship_city ship_state ship_zip ship_country
183                   payinfo paycvv paydate
184                   invoicing_list
185                   cust_pkg.pkgpart
186                   svc_acct.username svc_acct._password 
187                   customer_options
188                 );
189     $payby = 'BILL';
190  } elsif ( $format =~ /^svc_broadband/ ) {
191     @fields = qw( agent_custid refnum
192                   last first company address1 address2 city state zip country
193                   daytime night
194                   ship_last ship_first ship_company ship_address1 ship_address2
195                   ship_city ship_state ship_zip ship_country
196                   payinfo paycvv paydate
197                   invoicing_list
198                   cust_pkg.pkgpart cust_pkg.bill
199                 );
200     push @fields, map "svc_broadband.$_", qw( ip_addr mac_addr sectornum );
201     $payby = 'BILL';
202  } elsif ( $format =~ /^svc_external/ ) {
203     @fields = qw( agent_custid refnum
204                   last first company address1 address2 city state zip country
205                   daytime night
206                   ship_last ship_first ship_company ship_address1 ship_address2
207                   ship_city ship_state ship_zip ship_country
208                   payinfo paycvv paydate
209                   invoicing_list
210                   cust_pkg.pkgpart cust_pkg.bill
211                   svc_external.id svc_external.title
212                 );
213     push @fields, map "svc_phone.$_", qw( countrycode phonenum sip_password pin)
214       if $format eq 'svc_external_svc_phone';
215     $payby = 'BILL';
216   } elsif ( $format eq 'birthdates-acct_phone_hardware') {
217     @fields = qw( agent_custid refnum
218                   last first company address1 address2 city state zip country
219                   daytime night
220                   ship_last ship_first ship_company ship_address1 ship_address2
221                   ship_city ship_state ship_zip ship_country
222                   birthdate spouse_birthdate
223                   payinfo paycvv paydate
224                   invoicing_list
225                   cust_pkg.pkgpart cust_pkg.bill
226                   svc_acct.username svc_acct._password 
227                 );
228     push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
229     push @fields, map "svc_hardware.$_", qw(typenum ip_addr hw_addr serial);
230
231     $payby = 'BILL';
232   } elsif ( $format eq 'national_id-acct_phone') {
233     @fields = qw( agent_custid refnum
234                   last first company address1 address2 city state zip country
235                   daytime night
236                   ship_last ship_first ship_company ship_address1 ship_address2
237                   ship_city ship_state ship_zip ship_country
238                   national_id
239                   payinfo paycvv paydate
240                   invoicing_list
241                   cust_pkg.pkgpart cust_pkg.bill
242                   svc_acct.username svc_acct._password svc_acct.slipip
243                 );
244     push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
245
246     $payby = 'BILL';
247   } else {
248     die "unknown format $format";
249   }
250
251   my $count;
252   my $parser;
253   my @buffer = ();
254   if ( $type eq 'csv' ) {
255
256     eval "use Text::CSV_XS;";
257     die $@ if $@;
258
259     $parser = new Text::CSV_XS;
260
261     @buffer = split(/\r?\n/, slurp($filename) );
262     $count = scalar(@buffer);
263
264   } elsif ( $type eq 'xls' ) {
265
266     eval "use Spreadsheet::ParseExcel;";
267     die $@ if $@;
268
269     my $excel = Spreadsheet::ParseExcel::Workbook->new->Parse($filename);
270     $parser = $excel->{Worksheet}[0]; #first sheet
271
272     $count = $parser->{MaxRow} || $parser->{MinRow};
273     $count++;
274
275   } else {
276     die "Unknown file type $type\n";
277   }
278
279   #my $columns;
280
281   local $SIG{HUP} = 'IGNORE';
282   local $SIG{INT} = 'IGNORE';
283   local $SIG{QUIT} = 'IGNORE';
284   local $SIG{TERM} = 'IGNORE';
285   local $SIG{TSTP} = 'IGNORE';
286   local $SIG{PIPE} = 'IGNORE';
287
288   my $oldAutoCommit = $FS::UID::AutoCommit;
289   local $FS::UID::AutoCommit = 0;
290   my $dbh = dbh;
291
292   #implies ignore_expired_card
293   local($FS::cust_main::import) = 1;
294   local($FS::cust_main::import) = 1;
295   
296   my $line;
297   my $row = 0;
298   my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
299   while (1) {
300
301     my @columns = ();
302     if ( $type eq 'csv' ) {
303
304       last unless scalar(@buffer);
305       $line = shift(@buffer);
306
307       $parser->parse($line) or do {
308         $dbh->rollback if $oldAutoCommit;
309         return "can't parse: ". $parser->error_input();
310       };
311       @columns = $parser->fields();
312
313     } elsif ( $type eq 'xls' ) {
314
315       last if $row > ($parser->{MaxRow} || $parser->{MinRow})
316            || ! $parser->{Cells}[$row];
317
318       my @row = @{ $parser->{Cells}[$row] };
319       @columns = map $_->{Val}, @row;
320
321       #my $z = 'A';
322       #warn $z++. ": $_\n" for @columns;
323
324     } else {
325       die "Unknown file type $type\n";
326     }
327
328     #warn join('-',@columns);
329
330     my %cust_main = (
331       custbatch => $custbatch,
332       agentnum  => $agentnum,
333       refnum    => $refnum,
334       payby     => $payby, #default
335       paydate   => '12/2037', #default
336     );
337     my $billtime = time;
338     my %cust_pkg = ( pkgpart => $pkgpart );
339     my %svc_x = ();
340     my %bill_location = ();
341     my %ship_location = ();
342     foreach my $field ( @fields ) {
343
344       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
345
346         #$cust_pkg{$1} = parse_datetime( shift @$columns );
347         if ( $1 eq 'pkgpart' ) {
348           $cust_pkg{$1} = shift @columns;
349         } elsif ( $1 eq 'setup' ) {
350           $billtime = parse_datetime(shift @columns);
351         } else {
352           $cust_pkg{$1} = parse_datetime( shift @columns );
353         } 
354
355       } elsif ( $field =~ /^svc_acct\.(username|_password|slipip)$/ ) {
356
357         $svc_x{$1} = shift @columns;
358
359       } elsif ( $field =~ /^svc_broadband\.(ip_addr|mac_addr|sectornum)$/ ) {
360
361         $svc_x{$1} = shift @columns;
362
363       } elsif ( $field =~ /^svc_external\.(id|title)$/ ) {
364
365         $svc_x{$1} = shift @columns;
366
367       } elsif ( $field =~ /^svc_phone\.(countrycode|phonenum|sip_password|pin)$/ ) {
368         $svc_x{$1} = shift @columns;
369       
370       } elsif ( $field =~ /^svc_hardware\.(typenum|ip_addr|hw_addr|serial)$/ ) {
371
372         $svc_x{$1} = shift @columns;
373
374       } elsif ( $is_location{$field} ) {
375
376         $bill_location{$field} = shift @columns;
377
378       } elsif ( $field =~ /^ship_(.*)$/ and $is_location{$1} ) {
379
380         $ship_location{$1} = shift @columns;
381       
382       } else {
383
384         #refnum interception
385         if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
386
387           my $referral = $columns[0];
388           my %hash = ( 'referral' => $referral,
389                        'agentnum' => $agentnum,
390                        'disabled' => '',
391                      );
392
393           my $part_referral = qsearchs('part_referral', \%hash )
394                               || new FS::part_referral \%hash;
395
396           unless ( $part_referral->refnum ) {
397             my $error = $part_referral->insert;
398             if ( $error ) {
399               $dbh->rollback if $oldAutoCommit;
400               return "can't auto-insert advertising source: $referral: $error";
401             }
402           }
403
404           $columns[0] = $part_referral->refnum;
405         }
406
407         my $value = shift @columns;
408         $cust_main{$field} = $value if length($value);
409       }
410     } # foreach my $field
411     # finished importing columns
412
413     $bill_location{'country'} ||= $conf->config('countrydefault') || 'US';
414     $cust_main{'bill_location'} = FS::cust_location->new(\%bill_location);
415     if ( grep $_, values(%ship_location) ) {
416       $ship_location{'country'} ||= $conf->config('countrydefault') || 'US';
417       $cust_main{'ship_location'} = FS::cust_location->new(\%ship_location);
418     } else {
419       $cust_main{'ship_location'} = $cust_main{'bill_location'};
420     }
421
422     if ( defined $cust_main{'payinfo'} && length $cust_main{'payinfo'} ) {
423
424       if ( $cust_main{'payinfo'} =~ /^\s*(\d+\@[\d\.]+)\s*$/ ) {
425
426         $cust_main{'payby'}   = 'CHEK';
427         $cust_main{'payinfo'} = $1;
428
429       } else {
430
431         $cust_main{'payby'} = 'CARD';
432
433         if ($cust_main{'payinfo'} =~ /^\s*([AD]?)(.*)\s*$/) {
434           $cust_main{'payby'} = 'DCRD' if $1 eq 'D';
435           $cust_main{'payinfo'} = $2;
436         }
437
438       }
439
440     }
441
442     $cust_main{$_} = parse_datetime($cust_main{$_})
443       foreach grep $cust_main{$_},
444         qw( birthdate spouse_birthdate anniversary_date );
445
446     my $invoicing_list = $cust_main{'invoicing_list'}
447                            ? [ delete $cust_main{'invoicing_list'} ]
448                            : [];
449
450     my $customer_options = delete $cust_main{customer_options};
451     $cust_main{tax} = 'Y' if $customer_options =~ /taxexempt/i;
452     push @$invoicing_list, 'POST' if $customer_options =~ /postalinvoice/i;
453
454     my $cust_main = new FS::cust_main ( \%cust_main );
455
456     use Tie::RefHash;
457     tie my %hash, 'Tie::RefHash'; #this part is important
458
459     if ( $cust_pkg{'pkgpart'} ) {
460
461       unless ( $cust_pkg{'pkgpart'} =~ /^\d+$/ ) {
462         $dbh->rollback if $oldAutoCommit;
463         return 'illegal pkgpart: '. $cust_pkg{'pkgpart'};
464       }
465
466       my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
467
468       my @svc_x = ();
469       my $svcdb = '';
470       if ( $svc_x{'username'} ) {
471         $svcdb = 'svc_acct';
472       } elsif ( $svc_x{'id'} || $svc_x{'title'} ) {
473         $svcdb = 'svc_external';
474       } elsif ( $svc_x{ip_addr} || $svc_x{mac_addr} ) {
475         $svcdb = 'svc_broadband';
476       }
477
478       my $svc_phone = '';
479       if ( $svc_x{'countrycode'} || $svc_x{'phonenum'} ) {
480         $svc_phone = FS::svc_phone->new( {
481           map { $_ => delete($svc_x{$_}) }
482               qw( countrycode phonenum sip_password pin )
483         } );
484       }
485
486       my $svc_hardware = '';
487       if ( $svc_x{'typenum'} ) {
488         $svc_hardware = FS::svc_hardware->new( {
489           map { $_ => delete($svc_x{$_}) }
490             qw( typenum ip_addr hw_addr serial )
491         } );
492       }
493
494       if ( $svcdb || $svc_phone || $svc_hardware ) {
495         my $part_pkg = $cust_pkg->part_pkg;
496         unless ( $part_pkg ) {
497           $dbh->rollback if $oldAutoCommit;
498           return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
499         } 
500         if ( $svcdb ) {
501           $svc_x{svcpart} = $part_pkg->svcpart_unique_svcdb( $svcdb );
502           my $class = "FS::$svcdb";
503           push @svc_x, $class->new( \%svc_x );
504         }
505         if ( $svc_phone ) {
506           $svc_phone->svcpart( $part_pkg->svcpart_unique_svcdb('svc_phone') );
507           push @svc_x, $svc_phone;
508         }
509         if ( $svc_hardware ) {
510           $svc_hardware->svcpart( $part_pkg->svcpart_unique_svcdb('svc_hardware') );
511           push @svc_x, $svc_hardware;
512         }
513
514       }
515
516       $hash{$cust_pkg} = \@svc_x;
517     }
518
519     my $error = $cust_main->insert( \%hash, $invoicing_list );
520
521     if ( $error ) {
522       $dbh->rollback if $oldAutoCommit;
523       return "can't insert customer". ( $line ? " for $line" : '' ). ": $error";
524     }
525
526     if ( $format eq 'simple' ) {
527
528       #false laziness w/bill.cgi
529       $error = $cust_main->bill( 'time' => $billtime );
530       if ( $error ) {
531         $dbh->rollback if $oldAutoCommit;
532         return "can't bill customer for $line: $error";
533       }
534   
535       $error = $cust_main->apply_payments_and_credits;
536       if ( $error ) {
537         $dbh->rollback if $oldAutoCommit;
538         return "can't bill customer for $line: $error";
539       }
540
541       $error = $cust_main->collect();
542       if ( $error ) {
543         $dbh->rollback if $oldAutoCommit;
544         return "can't collect customer for $line: $error";
545       }
546
547     }
548
549     $row++;
550
551     if ( $job && time - $min_sec > $last ) { #progress bar
552       $job->update_statustext( int(100 * $row / $count) );
553       $last = time;
554     }
555
556   }
557
558   $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
559
560   return "Empty file!" unless $row;
561
562   ''; #no error
563
564 }
565
566 =head1 BUGS
567
568 Not enough documentation.
569
570 =head1 SEE ALSO
571
572 L<FS::cust_main>, L<FS::cust_pkg>,
573 L<FS::svc_acct>, L<FS::svc_external>, L<FS::svc_phone>
574
575 =cut
576
577 1;