DID inventory/import / bulk DID orders - phase 2, RT12754
[freeside.git] / bin / import-did-inventory
1 #!/usr/bin/perl
2
3 use strict;
4 use warnings;
5 use Text::CSV;
6 use FS::UID qw(adminsuidsetup);
7 use FS::cust_main_county;
8 use FS::Record qw(qsearch qsearchs dbh);
9 use DateTime::Format::Natural;
10 use FS::lata;
11 use FS::msa;
12 use FS::cust_main;
13 use FS::cust_main::Search qw(smart_search);
14 use FS::did_order;
15 use FS::did_order_item;
16 use FS::rate_center;
17 use FS::phone_avail;
18 use FS::did_vendor;
19 use FS::svc_phone;
20 use Data::Dumper;
21 use Time::HiRes qw(usleep ualarm gettimeofday tv_interval);
22
23 print "started time=".time."\n";
24
25 #### SET THESE! #################################
26 my $file = '/home/levinse/dids4.csv';
27 my $did_vendor_id = 1; 
28 my $dry = 0; 
29 my $internal_diddb_exportnum = 1; # IMPORTANT: set this to the correct exportnum or everything will go in wrong into phone_avail
30
31 # optionally set this one (probably not)
32 my %custname2num = (); # MyCust => 12345,
33 ################################################
34
35 my $user = shift;
36 adminsuidsetup $user;
37
38 local $SIG{HUP} = 'IGNORE';
39 local $SIG{INT} = 'IGNORE';
40 local $SIG{QUIT} = 'IGNORE';
41 local $SIG{TERM} = 'IGNORE';
42 local $SIG{TSTP} = 'IGNORE';
43 local $SIG{PIPE} = 'IGNORE';
44
45 my $oldAutoCommit = $FS::UID::AutoCommit;
46 local $FS::UID::AutoCommit = 0;
47 my $dbh = dbh;
48 my $max_date = time;
49 my $min_date = 1262304000; # January 1st 2010
50 my %did_order = ();
51 my %rate_center = ();
52 my %rate_center_abbrev = ();
53 my %cust2pkg = ();
54 my %msamap = ( 
55 # YOU CANNOT USE THE STATE/NPA/LATA OF A DID TO TRY TO FIND ITS MSA. IT HAS 
56 # NOTHING IN COMMON WITH THE STATE OF THE MSA. THERE IS SIMPLY INSUFFICIENT
57 # DATA IN THE CSV FILE TO DETERMINE CANONICAL MSA WITHOUT THIS:
58  'Washington DC' => 47900,
59  'Fort Lauderdale' => 33100,
60  'Cambridge' => 14460,
61  'Boise' => 14260,
62  'New York' => 35620,
63  'Aberdeen' => 10100,
64  'Bloomington' => 14020,
65  'Las Vegas' => 29820,
66  'Madison' => 31540,
67  'Miami' => 33100,
68  'Jackson' => 27140,
69  'St Cloud' => 41060,
70  'Stratford' => 14860,
71  
72 # more hax upon hax (the above are unique, no issues)
73  'Portland OR' => 38900, 
74  'Portland ME' => 38860, 
75 );
76 my $skipto = 0; 
77 my $limit = 0;
78 my $linenum = 1;
79 my $debug = 0;
80
81 # cache LATA and MSA tables in one query for performance
82 my @latas = qsearch('lata', {});
83 my %latas = map { $_->latanum => $_->description } @latas;
84
85 my @msas = qsearch('msa', {});
86 my %msas = map { $_->msanum => $_->description } @msas;
87
88 # now add in the brain-dead LATA hacks
89 $latas{636} = 'BRAINERD-FARGO ND';
90 $latas{920} = 'CONNECTICUT';
91 $latas{334} = 'AUBURN-HUNTINGTON IN';
92 $latas{232} = 'NORTHEAST - PA';
93 $latas{460} = 'SOUTHEAST FL GR-EA';
94 $latas{952} = 'TAMPA FLORIDA';
95 $latas{524} = 'KANSAS CITY';
96
97 my $parser = new DateTime::Format::Natural( 'time_zone' => 'local' );
98 sub parsedt {
99     my ($dt,$min,$max) = (shift,shift,shift);
100     my $epoch = $parser->parse_datetime($dt);
101     return $epoch->epoch 
102         if ($parser->success && $epoch->epoch >= $min && $epoch->epoch <= $max);
103     fatal("invalid date $dt (min=$min, max=$max)");
104 }
105
106 sub msatest {
107     my ($their,$our) = (shift,shift);
108     my $a = $our;
109     $a =~ s/,.*?$//;
110     return 1 if $a eq $their;
111     return 1 if ($our =~ /^([\w\s]+)-/ && $1 eq $their);
112     0;
113 }
114
115 sub trim {
116     my $str = shift;
117     $str =~ s/^\s+|\s+$//g;
118     $str;
119 }
120
121 sub suffer {
122     my $linenum = shift;
123     my @columns = @_;
124
125     my $did = trim($columns[0]);
126     my $npa = trim($columns[1]);
127     my $state = trim($columns[2]);
128     my $rate_center_abbrev = trim($columns[3]);
129     my $rate_center = trim($columns[4]);
130     my $customer = trim($columns[5]);
131     my $submitted = parsedt(trim($columns[7]),$min_date,$max_date);
132
133     my $ordernum = trim($columns[8]);
134     return if $ordernum eq 'Unknown'; 
135
136     my $confirmed = parsedt(trim($columns[9]),$submitted,$max_date);
137
138     # sometimes, we're in a non-Y2K-compliant bullshit format, differing from
139     # all the other dates. Other times, we randomly change formats multiple times
140     # in the middle of the file for absolutely no reason...wtf
141     my $received = trim($columns[10]); 
142     if ( $received =~ /^(\d{1,2})\/(\d{1,2})\/(\d{2})$/ ) {
143         $received = $2."/".$1."/20".$3;
144     } elsif ( $received !~ /^\d{2}\/\d{2}\/\d{4}$/ ) {
145         fatal("invalid received date $received");
146     }
147     if ( $ordernum == 300383 ) { # another hack due to bad data
148         $received = parsedt($received,1,$max_date) 
149     } else {
150         $received = parsedt($received,$confirmed,$max_date);
151     }
152
153     my $latanum = trim($columns[12]);
154     my $latadesc = trim($columns[13]);
155     my $msadesc = trim($columns[14]);
156
157     fatal("invalid DID and/or NPA or NPA doesn't match DID")
158         unless ($did =~ /^(\d{3})\d{7}$/ && $npa == $1);
159     fatal("invalid state, order #, LATA #, or LATA description")
160         unless ($state =~ /^[A-Z]{2}$/  && ($ordernum =~ /^\d+$/ || $ordernum eq 'Test') # more hacks
161                                         && $latanum =~ /^\d{3}$/ 
162                                         && $latadesc =~ /^[\w\s\-]+$/);
163
164
165     ### LATA ###
166
167     fatal("no lata found for latanum $latanum") unless exists($latas{$latanum}) 
168         || $latanum == 460;
169
170     # unsurprisingly, our idea of a LATA name doesn't always match their idea 
171     # of the same. Specifically, they randomly expand the state portion and
172     # abbreviate it arbitrarily
173
174     my $ourdesc = $latas{$latanum};
175
176     # strip off the fixed state abbreviation portion in ours
177     $ourdesc =~ s/ ..$//;
178     
179     # strip off the variable state abbreviation (or full name) portion in theirs
180     $latadesc =~ s/\s\w+$// unless uc($ourdesc) eq uc($latadesc); # yeah...long story :(
181
182     fatal("their LATA description '$latadesc' doesn't match our LATA description '$ourdesc'")
183         unless (uc($ourdesc) eq uc($latadesc) || $latanum == 460);
184
185
186     ### MSA ###
187
188     my $msanum = -1;
189     
190     # XXX: no idea what the MSA is for Danbury, so discard it for now and deal with it manually/later
191     $msadesc = '' if $msadesc eq 'Danbury';
192
193     # hax on hax
194     $msadesc = 'Portland OR' if ($msadesc eq 'Portland' && $state eq 'OR');
195     $msadesc = 'Portland ME' if ($msadesc eq 'Portland' && $state eq 'ME');
196
197     # not everything in their file has a MSA
198     if ( $msadesc =~ /^[\w\s]+$/ ) {
199
200         # their idea of a MSA differs from our idea of it
201         if ( exists($msamap{$msadesc}) ) {
202             $msanum = $msamap{$msadesc};
203         }
204         else {
205             my @msa = grep { msatest($msadesc,$_->description) } @msas;
206             fatal("multiple MSA matches for '$msadesc'") if(scalar(@msa) > 1);
207             $msanum = $msa[0]->msanum if scalar(@msa) == 1;
208             $msamap{$msadesc} = $msanum if $msanum != -1;
209         }
210         fatal("msa $msadesc not found") if $msanum == -1;
211         warn "$msadesc matched msanum $msanum for line $linenum\n" if $debug;
212     }
213
214
215     ### RATE CENTER ###
216     
217     if ( exists $rate_center{$rate_center} ) {
218         fatal("rate center abbreviation for '$rate_center' doesn't exist or doesn't match '$rate_center_abbrev'")
219             unless ( exists $rate_center_abbrev{$rate_center} &&
220                       $rate_center_abbrev{$rate_center} eq $rate_center_abbrev);
221     } else {
222         print "creating new rate center '$rate_center' '$rate_center_abbrev'\n";
223         my $rc = new FS::rate_center{ description => $rate_center };
224         my $error = $rc->insert;
225         fatal("can't insert rate center '$rate_center' '$rate_center_abbrev': $error") 
226             if $error;
227         $rate_center{$rate_center} = $rc->ratecenternum;
228         $rate_center_abbrev{$rate_center} = $rate_center_abbrev;
229     }
230     my $ratecenternum = $rate_center{$rate_center};
231    
232
233     my $order = order($ordernum,$submitted,$confirmed,$received,$customer);
234     my $order_item = order_item($order,$npa,$latanum,$state,$msanum,$ratecenternum);
235     my $phone_avail = phone_avail($order,$state,$did,$rate_center,$latanum,$msanum);
236     provision($did,$customer,$phone_avail) if $customer ne 'Stock';
237     
238     warn "Pass $linenum\n" if $debug;
239
240     my $time = time;
241     print "Done $linenum time=$time\n" if ($linenum % 100 == 0);
242 }
243
244 sub phone_avail {
245     my ($order,$state,$did,$rate_center,$latanum,$msanum) 
246                                         = (shift,shift,shift,shift,shift,shift);
247     $did =~ /^(\d{3})(\d{3})(\d{4})$/;
248     my $npa = $1;
249     my $nxx = $2;
250     my $station = $3;
251     my %hash = (
252         exportnum   => $internal_diddb_exportnum,
253         countrycode => '1',
254         state       => $state,
255         npa         => $npa,
256         nxx         => $nxx,
257         station     => $station,
258         name        => $rate_center,
259         rate_center_abbrev => $rate_center_abbrev{$rate_center},
260         ordernum    => $order->ordernum,
261         latanum     => $latanum,
262     );
263     $hash{'msanum'} = $msanum if $msanum != -1;
264     
265     my $pa = new FS::phone_avail{ %hash };
266     my $error = $pa->insert;
267     fatal("can't insert phone_avail: $error") if $error;
268
269     $pa;
270 }
271
272 sub order_item {
273     my($order,$npa,$latanum,$state,$msanum,$ratecenternum) 
274                                         = (shift,shift,shift,shift,shift,shift);
275     my %msa = ();
276     $msa{'msanum'} = $msanum if $msanum != -1;
277     my $oi;
278     my @order_item = $order->did_order_item; 
279     foreach my $order_item ( @order_item ) {
280         if($order_item->npa == $npa 
281             && $order_item->latanum == $latanum 
282             && $order_item->state eq $state 
283             && $order_item->ratecenternum == $ratecenternum
284             && (!$order_item->msanum || $order_item->msanum == $msanum)  ) {
285             fatal("Multiple order items") if $oi;
286             $oi = $order_item;
287         }
288     }
289     
290     if($oi) {
291         $oi->quantity($oi->quantity+1);
292         my $error = $oi->replace;
293         fatal("can't replace order item: $error") if $error;
294     } else {
295         $oi = new FS::did_order_item{ ordernum   => $order->ordernum,
296                                      npa        => $npa,
297                                      latanum    => $latanum,
298                                      state      => $state,
299                                      quantity   => 1,
300                                      ratecenternum => $ratecenternum,
301                                      %msa, };
302         my $error = $oi->insert;
303         fatal("can't insert order item: $error") if $error;
304     }
305
306     fatal("wtf2") unless $oi;
307
308     $oi;
309 }
310
311 sub order {
312     my($vendor_order_id,$submitted,$confirmed,$received,$customer) 
313                                             = (shift,shift,shift,shift,shift);
314     
315     my %cust = ();
316     if ( $customer ne 'Stock' ) {
317         if ( exists($custname2num{$customer}) ) {
318             $cust{'custnum'} = $custname2num{$customer};
319         } else {
320             print "new customer case for '$customer'\n";
321             my @cust_main = smart_search('search' => $customer);
322             fatal(scalar(@cust_main) . " customers found for $customer") 
323                 unless scalar(@cust_main) == 1;
324             my $cust_main = $cust_main[0];
325             
326             $cust{'custnum'} = $cust_main->custnum;
327             $custname2num{$customer} = $cust_main->custnum; 
328             $cust2pkg{$cust_main->custnum} = {};
329             
330             my @pkgs = $cust_main->ncancelled_pkgs;
331             fatal("no packages") unless scalar(@pkgs);
332
333             foreach my $pkg ( @pkgs ) {
334                 my @avail_part_svc = $pkg->available_part_svc;
335                 my @svcpart;
336                 foreach my $avail_part_svc ( @avail_part_svc ) {
337                     if ($avail_part_svc->svcdb eq 'svc_phone') {
338                         push @svcpart, $avail_part_svc->svcpart;
339                     }
340                 }
341                 fatal("multiple svc_phone services") if scalar(@svcpart) > 1;
342                 fatal("multiple packages with svc_phone services") 
343                     if (exists $cust2pkg{$cust_main->custnum}->{pkgnum}
344                             && scalar(@svcpart));
345                 if(scalar(@svcpart) == 1) {
346                     $cust2pkg{$cust_main->custnum}->{pkgnum} = $pkg->pkgnum;
347                     $cust2pkg{$cust_main->custnum}->{svcpart} = $svcpart[0];
348                 }
349             }
350
351             fatal("no pkg/svc") 
352                 unless (exists $cust2pkg{$cust_main->custnum}->{pkgnum}
353                         && exists $cust2pkg{$cust_main->custnum}->{svcpart});
354         }
355     }
356
357     my $o;
358     if( exists $did_order{$vendor_order_id} ) {
359         $o = $did_order{$vendor_order_id};
360         fatal("vendor order #$vendor_order_id - order data differs from one item to another")
361             unless ( ($o->submitted == $submitted
362                         || $o->vendor_order_id == 293011) # yet another bad data hack
363                     && $o->confirmed == $confirmed
364                     && $o->received == $received);
365 #        fatal("customer mismatch for vendor order #$vendor_order_id")
366 #           unless (    ($o->custnum && $cust{'custnum'} 
367 #                        && ($o->custnum == $cust{'custnum'} 
368 #                         || $vendor_order_id eq '293745' || $vendor_order_id eq '300001')
369 #                     )
370 #                    ||
371 #                   (!$o->custnum && !exists($cust{'custnum'}))
372 #             );
373     } else {
374         $o = new FS::did_order{ vendornum       => $did_vendor_id,
375                                 vendor_order_id => $vendor_order_id,
376                                 submitted       => $submitted,
377                                 confirmed       => $confirmed,
378                                 received        => $received,
379                                 %cust,          };
380         my $error = $o->insert;
381         fatal("can't insert vendor order #$vendor_order_id: $error") if $error;
382         $did_order{$vendor_order_id} = $o;
383     }
384
385     fatal("wtf") unless $o;
386     $o;
387 }
388
389 sub provision {
390     my($did,$customer,$phone_avail) = (shift,shift,shift);
391
392     local $FS::svc_Common::noexport_hack = 1;
393     # because of the above, we now need to do the internal did db
394     # export's job ourselves (set the svcnum for the DID in phone_avail)
395
396     fatal("customer not found") unless exists $cust2pkg{$custname2num{$customer}};
397
398     my $svc_phone = new FS::svc_phone({
399             pkgnum  => $cust2pkg{$custname2num{$customer}}->{pkgnum},
400             svcpart => $cust2pkg{$custname2num{$customer}}->{svcpart},
401             countrycode => 1,
402             phonenum    => $did,
403         });
404     
405     # XXX: THIS LINE CAUSES PERFORMANCE TO DEGRADE
406     # -unattaching the exports has no effect
407     # -after each successive call, the time taken to complete 100 rows becomes greater
408     # -commenting out this call results in a constant time taken to complete 100 rows
409     my $error = $svc_phone->insert;
410
411     fatal("can't insert svc_phone: $error") if $error;
412
413     $phone_avail->svcnum($svc_phone->svcnum);
414     $error = $phone_avail->replace;
415     fatal("can't replace phone_avail: $error") if $error;
416
417     '';
418 }
419
420 sub fatal {
421     my $msg = shift;
422     $dbh->rollback if $oldAutoCommit;
423     die $msg;
424 }
425
426 my $csv = new Text::CSV;
427 open (CSV, "<", $file) or die $!;
428 print "Starting main loop time=".time."\n";
429 while (<CSV>) {
430     if ( $linenum == 1 ) { # skip header
431         $linenum++;
432         next;
433     }
434
435     if( $skipto > $linenum ) { # debug stuff
436         $linenum++;
437         next;
438     }
439
440     last if $limit > 0 && $limit <= $linenum;
441
442     # kept getting these errors for many lines:
443     # "EIQ - Binary character inside quoted field, binary off"
444     $_ =~ s/[^[:ascii:]]//g;
445
446     if ($csv->parse($_)) {
447         my @columns = $csv->fields();
448         suffer($linenum,@columns);
449     } else {
450         my $err = $csv->error_diag . "(" . $csv->error_input . ")";
451         print "WARNING: failed to parse line $linenum: " . $csv->error_diag
452             . " (" . $csv->error_input . ")\n";
453     }
454     $linenum++;
455 }
456 close CSV;
457
458 fatal("COMMIT ABORTED DUE TO DRY RUN BEING ON") if $dry;
459 $dbh->commit or die $dbh->errstr if $oldAutoCommit;