RT# 83450 - added fields interface and map_location to export
[freeside.git] / FS / FS / part_export / saisei.pm
1 package FS::part_export::saisei;
2
3 use strict;
4 use vars qw( @ISA %info );
5 use base qw( FS::part_export );
6 use Date::Format 'time2str';
7 use Cpanel::JSON::XS;
8 use Storable qw(thaw);
9 use MIME::Base64;
10 use REST::Client;
11 use Data::Dumper;
12 use FS::Conf;
13
14 =pod
15
16 =head1 NAME
17
18 FS::part_export::saisei
19
20 =head1 SYNOPSIS
21
22 Saisei integration for Freeside
23
24 =head1 DESCRIPTION
25
26 This export offers basic svc_broadband provisioning for Saisei.
27
28 This is a customer integration with Saisei.  This will set up a rate plan and tie
29 the rate plan to a host and the access point via the Saisei API when the broadband service is provisioned.
30 It will also untie the host from the rate plan, setting it to the default rate plan via the API upon unprovisioning of the broadband service.
31
32 This will create and modify the rate plans at Saisei as soon as the broadband service attached to this export is created or modified.
33 This will also create and modify an access point at Saisei as soon as the tower is created or modified.
34
35 To use this export, follow the below instructions:
36
37 Create a new service definition and set the table to svc_broadband.  The service name will become the Saisei rate plan name.
38 Set the upload and download speed for the service. This is required to be able to export the service to Saisei.
39 Attach this Saisei export to this service.
40
41 Create a tower and add a sector to that tower.  The sector name will be the name of the access point,
42 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
43 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
44 Each sector will be attached to its tower access point using the Saisei uplink field.
45 Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
46
47 Create a package for the above created service, and order this package for a customer.
48
49 Provision the service, making sure to enter the IP address associated with this service and select the tower and sector for it's access point.
50 This provisioned service will then be exported as a host to Saisei.
51
52 Unprovisioning this service will set the host entry at Saisei to the default rate plan with the user and access point set to <none>.
53
54 After this export is set up and attached to a service, you can export the already provisioned services by clicking the link Export provisioned services attached to this export.
55 Clicking on this link will export all services attached to this export not currently exported to Saisei.
56
57 This module also provides generic methods for working through the L</Saisei API>.
58
59 =cut
60
61 tie my %scripts, 'Tie::IxHash',
62   'export_provisioned_services'  => { component => '/elements/popup_link.html',
63                                       label     => 'Export provisioned services',
64                                       description => 'will export provisioned services of part service with Saisei export attached.',
65                                       html_label => '<b>Export provisioned services attached to this export.</b>',
66                                       error_url  => '/edit/part_export.cgi?',
67                                       success_message => 'Saisei export of provisioned services successful',
68                                     },
69   'export_all_towers_sectors'    => { component => '/elements/popup_link.html',
70                                       label     => 'Export of all towers and sectors',
71                                       description => 'Will force an export of all towers and sectors to Saisei as access points.',
72                                       html_label => '<b>Export all towers and sectors.</b>',
73                                       error_url  => '/edit/part_export.cgi?',
74                                       success_message => 'Saisei export of towers and sectors as access points successful',
75                                     },
76 ;
77
78 tie my %options, 'Tie::IxHash',
79   'port'             => { label => 'Port',
80                           default => 5000 },
81   'username'         => { label => 'Saisei API User Name',
82                           default => '' },
83   'password'         => { label => 'Saisei API Password',
84                           default => '' },
85   'interface'        => { label => 'Saisei Access Point Interface',
86                           default => '' },
87   'debug'            => { type => 'checkbox',
88                           label => 'Enable debug warnings' },
89 ;
90
91 %info = (
92   'svc'             => 'svc_broadband',
93   'desc'            => 'Export broadband service/account to Saisei',
94   'options'         => \%options,
95   'scripts'         => \%scripts,
96   'notes'           => <<'END',
97 This is a customer integration with Saisei.  This will set up a rate plan and tie
98 the rate plan to a host and the access point via the Saisei API when the broadband service is provisioned.
99 It will also untie the host from the rate plan, setting it to the default rate plan via the API upon unprovisioning of the broadband service.
100 <P>
101 This will create and modify the rate plans at Saisei as soon as the broadband service attached to this export is created or modified.
102 This will also create and modify an access point at Saisei as soon as the tower is created or modified.
103 <P>
104 To use this export, follow the below instructions:
105 <P>
106 <OL>
107 <LI>
108 Create a new service definition and set the table to svc_broadband.  The service name will become the Saisei rate plan name.
109 Set the upload speed, download speed, and tower to be required for the service. This is required to be able to export the service to Saisei.
110 Attach this Saisei export to this service.
111 </LI>
112 <P>
113 <LI>
114 Create a tower and add a sector to that tower.  The sector name will be the name of the access point,
115 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
116 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
117 Each sector will be attached to its tower access point using the Saisei uplink field.
118 Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
119 </LI>
120 <P>
121 <LI>
122 Create a package for the above created service, and order this package for a customer.
123 </LI>
124 <P>
125 <LI>
126 Provision the service, making sure to enter the IP address associated with this service, the upload and download speed are correct, and select the tower and sector for it's access point.
127 This provisioned service will then be exported as a host to Saisei.
128 <P>
129 Unprovisioning this service will set the host entry at Saisei to the default rate plan with the user and access point set to <i>none</i>.
130 </LI>
131 <P>
132 <LI>
133 After this export is set up and attached to a service, you can export the already provisioned services by clicking the link <b>Export provisioned services attached to this export</b>.
134 Clicking on this link will export all services attached to this export not currently exported to Saisei.
135 </LI>
136 </OL>
137 <P>
138 <A HREF="http://www.freeside.biz/mediawiki/index.php/Saisei_provisioning_export" target="_new">Documentation</a>
139 END
140 );
141
142 sub _export_insert {
143   my ($self, $svc_broadband) = @_;
144
145   my $rateplan_name = $self->get_rateplan_name($svc_broadband);
146
147   # check for existing rate plan
148   my $existing_rateplan;
149   $existing_rateplan = $self->api_get_rateplan($rateplan_name) unless $self->{'__saisei_error'};
150
151   # if no existing rate plan create one and modify it.
152   $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan;
153   $self->api_modify_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan);
154   return $self->api_error if $self->{'__saisei_error'};
155
156   # set rateplan to existing one or newly created one.
157   my $rateplan = $existing_rateplan ? $existing_rateplan : $self->api_get_rateplan($rateplan_name);
158
159   my $username = $svc_broadband->{Hash}->{svcnum};
160   my $description = $svc_broadband->{Hash}->{description};
161   my $svc_location;
162   $svc_location = $svc_broadband->{Hash}->{latitude}.','.$svc_broadband->{Hash}->{longitude} if ($svc_broadband->{Hash}->{latitude} && $svc_broadband->{Hash}->{longitude});
163
164   if (!$username) {
165     $self->{'__saisei_error'} = 'no username - can not export';
166     return $self->api_error;
167   }
168   else {
169     # check for existing user.
170     my $existing_user;
171     $existing_user = $self->api_get_user($username) unless $self->{'__saisei_error'};
172  
173     # if no existing user create one.
174     $self->api_create_user($username, $description, $svc_location) unless $existing_user;
175     return $self->api_error if $self->{'__saisei_error'};
176
177     # set user to existing one or newly created one.
178     my $user = $existing_user ? $existing_user : $self->api_get_user($username);
179
180     ## add access point
181     my $tower_sector = FS::Record::qsearchs({
182       'table'     => 'tower_sector',
183       'select'    => 'tower.towername,
184                       tower.up_rate_limit as tower_upratelimit,
185                       tower.down_rate_limit as tower_downratelimit,
186                       tower_sector.sectorname,
187                       tower_sector.towernum,
188                       tower_sector.up_rate_limit as sector_upratelimit,
189                       tower_sector.down_rate_limit as sector_downratelimit,
190                       tower.latitude,
191                       tower.longitude',
192       'addl_from' => 'LEFT JOIN tower USING ( towernum )',
193       'hashref'   => {
194                         'sectornum' => $svc_broadband->{Hash}->{sectornum},
195                      },
196     });
197
198     my $tower_location;
199     $tower_location = $tower_sector->{Hash}->{latitude}.','.$tower_sector->{Hash}->{longitude} if ($tower_sector->{Hash}->{latitude} && $tower_sector->{Hash}->{longitude});
200
201     my $tower_name = $tower_sector->{Hash}->{towername};
202     $tower_name =~ s/\s/_/g;
203
204     my $tower_opt = {
205       'tower_name'           => $tower_name,
206       'tower_num'            => $tower_sector->{Hash}->{towernum},
207       'tower_uprate_limit'   => $tower_sector->{Hash}->{tower_upratelimit},
208       'tower_downrate_limit' => $tower_sector->{Hash}->{tower_downratelimit},
209     };
210     $tower_opt->{'location'} = $tower_location if $tower_location;
211
212     my $tower_ap = process_tower($self, $tower_opt);
213     return $self->api_error if $self->{'__saisei_error'};
214
215     my $sector_name = $tower_sector->{Hash}->{sectorname};
216     $sector_name =~ s/\s/_/g;
217
218     my $sector_opt = {
219       'tower_name'            => $tower_name,
220       'tower_num'             => $tower_sector->{Hash}->{towernum},
221       'sector_name'           => $sector_name,
222       'sector_uprate_limit'   => $tower_sector->{Hash}->{sector_upratelimit},
223       'sector_downrate_limit' => $tower_sector->{Hash}->{sector_downratelimit},
224       'rateplan'              => $rateplan_name,
225     };
226     $sector_opt->{'location'} = $tower_location if $tower_location;
227
228     my $accesspoint = process_sector($self, $sector_opt);
229     return $self->api_error if $self->{'__saisei_error'};
230
231 ## get custnum and pkgpart from cust_pkg for virtual access point
232     my $cust_pkg = FS::Record::qsearchs({
233       'table'     => 'cust_pkg',
234       'hashref'   => { 'pkgnum' => $svc_broadband->{Hash}->{pkgnum}, },
235     });
236     my $virtual_ap_name = $cust_pkg->{Hash}->{custnum}.'_'.$cust_pkg->{Hash}->{pkgpart}.'_'.$svc_broadband->{Hash}->{speed_down}.'_'.$svc_broadband->{Hash}->{speed_up};
237
238     my $virtual_ap_opt = {
239       'virtual_name'           => $virtual_ap_name,
240       'sector_name'            => $sector_name,
241       'virtual_uprate_limit'   => $svc_broadband->{Hash}->{speed_up},
242       'virtual_downrate_limit' => $svc_broadband->{Hash}->{speed_down},
243     };
244     my $virtual_ap = process_virtual_ap($self, $virtual_ap_opt);
245     return $self->api_error if $self->{'__saisei_error'};
246
247     ## tie host to user add sector name as access point.
248     my $host_opt = {
249       'user'        => $user->{collection}->[0]->{name},
250       'rateplan'    => $rateplan->{collection}->[0]->{name},
251       'ip'          => $svc_broadband->{Hash}->{ip_addr},
252       'accesspoint' => $virtual_ap->{collection}->[0]->{name},
253       'location'    => $svc_location,
254     };
255     $self->api_add_host_to_user($host_opt)
256       unless $self->{'__saisei_error'};
257   }
258
259   return $self->api_error;
260
261 }
262
263 sub _export_replace {
264   my ($self, $svc_broadband) = @_;
265   my $error = $self->_export_insert($svc_broadband);
266   return $error;
267 }
268
269 sub _export_delete {
270   my ($self, $svc_broadband) = @_;
271
272   my $rateplan_name = $self->get_rateplan_name($svc_broadband);
273
274   my $username = $svc_broadband->{Hash}->{svcnum};
275
276   ## untie host to user
277   $self->api_delete_host_to_user($username, $rateplan_name, $svc_broadband->{Hash}->{ip_addr}) unless $self->{'__saisei_error'};
278
279   return '';
280 }
281
282 sub _export_suspend {
283   my ($self, $svc_broadband) = @_;
284   return '';
285 }
286
287 sub _export_unsuspend {
288   my ($self, $svc_broadband) = @_;
289   return '';
290 }
291
292 sub export_partsvc {
293   my ($self, $svc_part) = @_;
294
295   my $fcc_477_speeds;
296   if ($svc_part->{Hash}->{svc_broadband__speed_down} eq "down" || $svc_part->{Hash}->{svc_broadband__speed_up} eq "up") {
297     for my $type (qw( down up )) {
298       my $speed_type = "broadband_".$type."stream";
299       foreach my $pkg_svc (FS::Record::qsearch({
300         'table'     => 'pkg_svc',
301         'select'    => 'pkg_svc.*, part_pkg_fcc_option.fccoptionname, part_pkg_fcc_option.optionvalue',
302         'addl_from' => ' LEFT JOIN part_pkg_fcc_option USING (pkgpart) ',
303         'extra_sql' => " WHERE pkg_svc.svcpart = ".$svc_part->{Hash}->{svcpart}." AND pkg_svc.quantity > 0 AND part_pkg_fcc_option.fccoptionname = '".$speed_type."'",
304       })) { $fcc_477_speeds->{
305         $pkg_svc->{Hash}->{pkgpart}}->{$speed_type} = $pkg_svc->{Hash}->{optionvalue} * 1000 unless !$pkg_svc->{Hash}->{optionvalue}; }
306     }
307   }
308   else {
309     $fcc_477_speeds->{1}->{broadband_downstream} = $svc_part->{Hash}->{"svc_broadband__speed_down"};
310     $fcc_477_speeds->{1}->{broadband_upstream} = $svc_part->{Hash}->{"svc_broadband__speed_up"};
311   }
312
313   foreach my $key (keys %$fcc_477_speeds) {
314
315     $svc_part->{Hash}->{speed_down} = $fcc_477_speeds->{$key}->{broadband_downstream};
316     $svc_part->{Hash}->{speed_up} = $fcc_477_speeds->{$key}->{broadband_upstream};
317     $svc_part->{Hash}->{svc_broadband__speed_down} = $fcc_477_speeds->{$key}->{broadband_downstream};
318     $svc_part->{Hash}->{svc_broadband__speed_up} = $fcc_477_speeds->{$key}->{broadband_upstream};
319
320     my $temp_svc = $svc_part->{Hash};
321     my $svc_broadband = {};
322     map { if ($_ =~ /^svc_broadband__(.*)$/) { $svc_broadband->{Hash}->{$1} = $temp_svc->{$_}; }  } keys %$temp_svc;
323
324     my $rateplan_name = $self->get_rateplan_name($svc_broadband, $svc_part->{Hash}->{svc});
325
326     # check for existing rate plan
327     my $existing_rateplan;
328     $existing_rateplan = $self->api_get_rateplan($rateplan_name) unless $self->{'__saisei_error'};
329
330     # Modify the existing rate plan with new service data.
331     $self->api_modify_existing_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || !$existing_rateplan);
332
333     # if no existing rate plan create one and modify it.
334     $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan;
335     $self->api_modify_rateplan($svc_part, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan);
336
337   }
338
339   return $self->api_error;
340
341 }
342
343 sub export_tower_sector {
344   my ($self, $tower) = @_;
345
346   my $tower_location;
347   $tower_location = $tower->{Hash}->{latitude}.','.$tower->{Hash}->{longitude} if ($tower->{Hash}->{latitude} && $tower->{Hash}->{longitude});
348
349   #modify tower or create it.
350   my $tower_name = $tower->{Hash}->{towername};
351   $tower_name =~ s/\s/_/g;
352   my $tower_opt = {
353     'tower_name'           => $tower_name,
354     'tower_num'            => $tower->{Hash}->{towernum},
355     'tower_uprate_limit'   => $tower->{Hash}->{up_rate_limit},
356     'tower_downrate_limit' => $tower->{Hash}->{down_rate_limit},
357     'modify_existing'      => '1', # modify an existing access point with this info
358   };
359   $tower_opt->{'location'} = $tower_location if $tower_location;
360
361   my $tower_access_point = process_tower($self, $tower_opt);
362     return $tower_access_point if $tower_access_point->{error};
363
364   #get list of all access points
365   my $hash_opt = {
366       'table'     => 'tower_sector',
367       'select'    => '*',
368       'hashref'   => { 'towernum' => $tower->{Hash}->{towernum}, },
369   };
370
371   #for each one modify or create it.
372   foreach my $tower_sector ( FS::Record::qsearch($hash_opt) ) {
373     next if $tower_sector->{Hash}->{sectorname} eq "_default";
374     my $sector_name = $tower_sector->{Hash}->{sectorname};
375     $sector_name =~ s/\s/_/g;
376     my $sector_opt = {
377       'tower_name'            => $tower_name,
378       'tower_num'             => $tower_sector->{Hash}->{towernum},
379       'sector_name'           => $sector_name,
380       'sector_uprate_limit'   => $tower_sector->{Hash}->{up_rate_limit},
381       'sector_downrate_limit' => $tower_sector->{Hash}->{down_rate_limit},
382       'modify_existing'       => '1', # modify an existing access point with this info
383     };
384     $sector_opt->{'location'} = $tower_location if $tower_location;
385
386     my $sector_access_point = process_sector($self, $sector_opt) unless ($sector_name eq "_default");
387       return $sector_access_point if $sector_access_point->{error};
388   }
389
390   return { error => $self->api_error, };
391 }
392
393 ## creates the rateplan name
394 sub get_rateplan_name {
395   my ($self, $svc_broadband, $svc_name) = @_;
396
397   my $service_part = FS::Record::qsearchs( 'part_svc', { 'svcpart' => $svc_broadband->{Hash}->{svcpart} } ) unless $svc_name;
398   my $service_name = $svc_name ? $svc_name : $service_part->{Hash}->{svc};
399
400   my $rateplan_name = $service_name . " " . $svc_broadband->{Hash}->{speed_down} . "-" . $svc_broadband->{Hash}->{speed_up};
401   $rateplan_name =~ s/\s/_/g; $rateplan_name =~ s/[^A-Za-z0-9\-_]//g;
402
403   return $rateplan_name;
404 }
405
406 =head1 Saisei API
407
408 These methods allow access to the Saisei API using the credentials
409 set in the export options.
410
411 =cut
412
413 =head2 api_call
414
415 Accepts I<$method>, I<$path>, I<$params> hashref and optional.
416 Places an api call to the specified path and method with the specified params.
417 Returns the decoded json object returned by the api call.
418 Returns empty on failure;  retrieve error messages using L</api_error>.
419
420 =cut
421
422 sub api_call {
423   my ($self,$method,$path,$params) = @_;
424
425   $self->{'__saisei_error'} = '';
426   my $auth_info = $self->option('username') . ':' . $self->option('password');
427   $params ||= {};
428
429   warn "Calling $method on http://"
430     .$self->{Hash}->{machine}.':'.$self->option('port')
431     ."/rest/top/configurations/running/$path\n" if $self->option('debug');
432
433   my $data = encode_json($params) if keys %{ $params };
434
435   my $client = REST::Client->new();
436   $client->addHeader("Authorization", "Basic ".encode_base64($auth_info));
437   $client->setHost('http://'.$self->{Hash}->{machine}.':'.$self->option('port'));
438   $client->$method('/rest/top/configurations/running'.$path, $data, { "Content-type" => 'application/json'});
439
440   warn "Saisei Response Code is ".$client->responseCode()."\n" if $self->option('debug');
441
442   my $result;
443
444   if ($client->responseCode() eq '200' || $client->responseCode() eq '201') {
445     eval { $result = decode_json($client->responseContent()) };
446     unless ($result) {
447       $self->{'__saisei_error'} = "There was an error decoding the JSON data from Saisei.  Bad JSON data logged in error log if debug option was set.";
448       warn "Saisei RC 201 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
449       return;
450     }
451   }
452   elsif ($client->responseCode() eq '404') {
453     eval { $result = decode_json($client->responseContent()) };
454     unless ($result) {
455       $self->{'__saisei_error'} = "There was an error decoding the JSON data from Saisei.  Bad JSON data logged in error log if debug option was set.";
456       warn "Saisei RC 404 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
457       return;
458     }
459     ## check if message is for empty hash.
460     my($does_not_exist) = $result->{message} =~ /'(.*)' does not exist$/;
461     $self->{'__saisei_error'} = "Saisei Error: ".$result->{message} unless $does_not_exist;
462     warn "Saisei Response Content is\n".$client->responseContent."\n" if ($self->option('debug') && !$does_not_exist);
463     return;
464   }
465   elsif ($client->responseCode() eq '500') {
466     $self->{'__saisei_error'} = "Could not connect to the Saisei export host machine (".$self->{Hash}->{machine}.':'.$self->option('port').") during $method , we received the responce code: " . $client->responseCode();
467     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
468     return;
469   }
470   else {
471     $self->{'__saisei_error'} = "Received Bad response from server during $method $path $data, we received responce code: " . $client->responseCode() . " " . $client->responseContent;
472     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
473     return; 
474   }
475
476   return $result;
477   
478 }
479
480 =head2 api_error
481
482 Returns the error string set by L</Saisei API> methods,
483 or a blank string if most recent call produced no errors.
484
485 =cut
486
487 sub api_error {
488   my $self = shift;
489   return $self->{'__saisei_error'} || '';
490 }
491
492 =head2 api_get_policies
493
494 Gets a list of global policies.
495
496 =cut
497
498 sub api_get_policies {
499   my $self = shift;
500
501   my $get_policies = $self->api_call("GET", '/policies/?token=1&order=name&start=0&limit=20&select=name%2Cpercent_rate%2Cassured%2C');
502   return if $self->api_error;
503   $self->{'__saisei_error'} = "Did not receive any global policies from Saisei."
504     unless $get_policies;
505
506   return $get_policies->{collection};
507 }
508
509 =head2 api_get_rateplan
510
511 Gets rateplan info for specific rateplan.
512
513 =cut
514
515 sub api_get_rateplan {
516   my $self = shift;
517   my $rateplan = shift;
518
519   my $get_rateplan = $self->api_call("GET", "/rate_plans/$rateplan");
520   return if $self->api_error;
521
522   return $get_rateplan;
523 }
524
525 =head2 api_get_user
526
527 Gets user info for specific user.
528
529 =cut
530
531 sub api_get_user {
532   my $self = shift;
533   my $user = shift;
534
535   my $get_user = $self->api_call("GET", "/users/$user");
536   return if $self->api_error;
537
538   return $get_user;
539 }
540
541 =head2 api_get_accesspoint
542
543 Gets user info for specific access point.
544
545 =cut
546
547 sub api_get_accesspoint {
548   my $self = shift;
549   my $accesspoint = shift;
550
551   my $get_accesspoint = $self->api_call("GET", "/access_points/$accesspoint");
552   return if $self->api_error;
553
554   return $get_accesspoint;
555 }
556
557 =head2 api_get_host
558
559 Gets user info for specific host.
560
561 =cut
562
563 sub api_get_host {
564   my $self = shift;
565   my $ip = shift;
566
567   my $get_host = $self->api_call("GET", "/hosts/$ip");
568
569   return { message => $self->api_error, } if $self->api_error;
570
571   return $get_host;
572 }
573
574 =head2 api_create_rateplan
575
576 Creates a rateplan.
577
578 =cut
579
580 sub api_create_rateplan {
581   my ($self, $svc, $rateplan) = @_;
582
583   $self->{'__saisei_error'} = "There is no download speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a download speed set for them." if !$svc->{Hash}->{speed_down};
584   $self->{'__saisei_error'} = "There is no upload speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a upload speed set for them." if !$svc->{Hash}->{speed_up};
585
586   my $new_rateplan = $self->api_call(
587       "PUT", 
588       "/rate_plans/$rateplan",
589       {
590         'downstream_rate' => $svc->{Hash}->{speed_down},
591         'upstream_rate' => $svc->{Hash}->{speed_up},
592       },
593   ) unless $self->{'__saisei_error'};
594
595   $self->{'__saisei_error'} = "Saisei could not create the rate plan $rateplan."
596     unless ($new_rateplan || $self->{'__saisei_error'});
597
598   return $new_rateplan;
599
600 }
601
602 =head2 api_modify_rateplan
603
604 Modify a new rateplan.
605
606 =cut
607
608 sub api_modify_rateplan {
609   my ($self,$svc,$rateplan_name) = @_;
610
611   # get policy list
612   my $policies = $self->api_get_policies();
613
614   foreach my $policy (@$policies) {
615     my $policyname = $policy->{name};
616     my $rate_multiplier = '';
617     if ($policy->{background}) { $rate_multiplier = ".01"; }
618     my $modified_rateplan = $self->api_call(
619       "PUT", 
620       "/rate_plans/$rateplan_name/partitions/$policyname",
621       {
622         'restricted'      =>  $policy->{assured},         # policy_assured_flag
623         'rate_multiplier' => $rate_multiplier,           # policy_background 0.1
624         'rate'            =>  $policy->{percent_rate}, # policy_percent_rate
625       },
626     );
627
628     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name after it was created."
629       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
630     
631   }
632
633   return;
634  
635 }
636
637 =head2 api_modify_existing_rateplan
638
639 Modify a existing rateplan.
640
641 =cut
642
643 sub api_modify_existing_rateplan {
644   my ($self,$svc,$rateplan_name) = @_;
645
646   $self->{'__saisei_error'} = "There is no download speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan_name."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a download speed set for them." if !$svc->{Hash}->{speed_down};
647   $self->{'__saisei_error'} = "There is no upload speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan_name."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a upload speed set for them." if !$svc->{Hash}->{speed_up};
648
649   my $modified_rateplan = $self->api_call(
650     "PUT",
651     "/rate_plans/$rateplan_name",
652     {
653       'downstream_rate' => $svc->{Hash}->{speed_down},
654       'upstream_rate' => $svc->{Hash}->{speed_up},
655     },
656   );
657
658     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name."
659       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
660
661   return;
662
663 }
664
665 =head2 api_create_user
666
667 Creates a user.
668
669 =cut
670
671 sub api_create_user {
672   my ($self,$user, $description, $location) = @_;
673
674   my $user_hash = {
675     'description' => $description,
676   };
677   $user_hash->{'map_location'} = $location if $location;
678
679   my $new_user = $self->api_call(
680       "PUT", 
681       "/users/$user",
682       $user_hash,
683   );
684
685   $self->{'__saisei_error'} = "Saisei could not create the user $user"
686     unless ($new_user || $self->{'__saisei_error'}); # should never happen
687
688   return $new_user;
689
690 }
691
692 =head2 api_create_accesspoint
693
694 Creates a access point.
695
696 =cut
697
698 sub api_create_accesspoint {
699   my ($self,$accesspoint, $upratelimit, $downratelimit, $location) = @_;
700
701   my $ap_hash = {
702     'downstream_rate_limit' => $downratelimit,
703     'upstream_rate_limit'   => $upratelimit,
704     'interface'             => $self->option('interface'),
705   };
706   $ap_hash->{'map_location'} = $location if $location;
707
708   my $new_accesspoint = $self->api_call(
709       "PUT",
710       "/access_points/$accesspoint",
711       $ap_hash,
712   );
713
714   $self->{'__saisei_error'} = "Saisei could not create the access point $accesspoint"
715     unless ($new_accesspoint || $self->{'__saisei_error'}); # should never happen
716   return;
717
718 }
719
720 =head2 api_modify_accesspoint
721
722 Modify a new access point.
723
724 =cut
725
726 sub api_modify_accesspoint {
727   my ($self, $accesspoint, $uplink, $location) = @_;
728
729   my $ap_hash = {
730     'uplink'    => $uplink,
731     'interface' => $self->option('interface'),
732   };
733   $ap_hash->{'map_location'} = $location if $location;
734
735   my $modified_accesspoint = $self->api_call(
736     "PUT",
737     "/access_points/$accesspoint",
738     $ap_hash,
739   );
740
741   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint after it was created."
742     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
743
744   return;
745
746 }
747
748 =head2 api_modify_existing_accesspoint
749
750 Modify a existing accesspoint.
751
752 =cut
753
754 sub api_modify_existing_accesspoint {
755   my ($self, $accesspoint, $uplink, $upratelimit, $downratelimit, $location) = @_;
756
757   my $ap_hash = {
758     'downstream_rate_limit' => $downratelimit,
759     'upstream_rate_limit'   => $upratelimit,
760     'interface'             => $self->option('interface'),
761 #   'uplink'                => $uplink, # name of attached access point
762   };
763   $ap_hash->{'map_location'} = $location if $location;
764
765   my $modified_accesspoint = $self->api_call(
766     "PUT",
767     "/access_points/$accesspoint",
768     $ap_hash,
769   );
770
771   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint."
772     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
773
774   return;
775
776 }
777
778 =head2 api_add_host_to_user
779
780 ties host to user, rateplan and default access point.
781
782 =cut
783
784 sub api_add_host_to_user {
785 #  my ($self,$user, $rateplan, $ip, $accesspoint, $location) = @_;
786   my ($self,$opt) = @_;
787   my $ip = $opt->{'ip'};
788   my $location = $opt->{'location'};
789
790   my $newhost_hash = {
791     'user'         => $opt->{'user'},
792     'rate_plan'    => $opt->{'rateplan'},
793     'access_point' => $opt->{'accesspoint'},
794   };
795   $newhost_hash->{'map_location'} = $location if $location;
796
797   my $new_host = $self->api_call(
798       "PUT", 
799       "/hosts/$ip",
800       $newhost_hash,
801   );
802
803   $self->{'__saisei_error'} = "Saisei could not create the host $ip"
804     unless ($new_host || $self->{'__saisei_error'}); # should never happen
805
806   return $new_host;
807
808 }
809
810 =head2 api_delete_host_to_user
811
812 unties host from user and rateplan.
813 this will set the host entry at Saisei to the default rate plan with the user and access point set to <none>.
814
815 =cut
816
817 sub api_delete_host_to_user {
818   my ($self,$user, $rateplan, $ip) = @_;
819
820   my $default_rate_plan = $self->api_call("GET", '?token=1&select=default_rate_plan');
821     return if $self->api_error;
822   $self->{'__saisei_error'} = "Can not delete the host as Saisei did not return a default rate plan. Please make sure Saisei has a default rateplan setup."
823     unless $default_rate_plan;
824
825   my $default_rateplan_name = $default_rate_plan->{collection}->[0]->{default_rate_plan}->{link}->{name};
826
827   my $delete_host = $self->api_call(
828       "PUT",
829       "/hosts/$ip",
830       {
831         'user'          => '<none>',
832         'access_point'  => '<none>',
833         'rate_plan'     => $default_rateplan_name,
834       },
835   );
836
837   $self->{'__saisei_error'} = "Saisei could not delete the host $ip"
838     unless ($delete_host || $self->{'__saisei_error'}); # should never happen
839
840   return $delete_host;
841
842 }
843
844 sub process_tower {
845   my ($self, $opt) = @_;
846
847   if (!$opt->{tower_uprate_limit} || !$opt->{tower_downrate_limit}) {
848     $self->{'__saisei_error'} = "Could not export tower !--tower,".$opt->{tower_num}.",".$opt->{tower_name}."--! because there was no up or down rates attached to the tower.  Saisei requires a up and down rate be attached to each tower.";
849     return { error => $self->api_error, };
850   }
851
852   my $existing_tower_ap;
853   my $tower_name = $opt->{tower_name};
854   my $location = $opt->{location};
855
856   #check if tower has been set up as an access point.
857   $existing_tower_ap = $self->api_get_accesspoint($tower_name) unless $self->{'__saisei_error'};
858
859   # modify the existing accesspoint if changing tower .
860   $self->api_modify_existing_accesspoint (
861     $tower_name,
862     '', # tower does not have a uplink on sectors.
863     $opt->{tower_uprate_limit},
864     $opt->{tower_downrate_limit},
865     $location,
866   ) if $existing_tower_ap->{collection} && $opt->{modify_existing};
867
868   #if tower does not exist as an access point create it.
869   $self->api_create_accesspoint(
870       $tower_name,
871       $opt->{tower_uprate_limit},
872       $opt->{tower_downrate_limit},
873       $location,
874   ) unless $existing_tower_ap->{collection};
875
876   my $accesspoint = $self->api_get_accesspoint($tower_name);
877
878   return { error => $self->api_error, } if $self->api_error;
879   return $accesspoint;
880 }
881
882 sub process_sector {
883   my ($self, $opt) = @_;
884
885   if (!$opt->{sector_name} || $opt->{sector_name} eq '_default') {
886     $self->{'__saisei_error'} = "No sector attached to Tower (".$opt->{tower_name}.") for service ".$opt->{'rateplan'}.".  Saisei requires a tower sector to be attached to each service that is exported to Saisei.";
887     return { error => $self->api_error, };
888   }
889
890   if (!$opt->{sector_uprate_limit} || !$opt->{sector_downrate_limit}) {
891     $self->{'__saisei_error'} = "Could not export sector !--tower,".$opt->{tower_num}.",".$opt->{sector_name}."--! because there was no up or down rates attached to the sector.  Saisei requires a up and down rate be attached to each sector.";
892     return { error => $self->api_error, };
893   }
894
895   my $existing_sector_ap;
896   my $sector_name = $opt->{sector_name};
897   my $location = $opt->{location};
898
899   #check if sector has been set up as an access point.
900   $existing_sector_ap = $self->api_get_accesspoint($sector_name);
901
902   # modify the existing accesspoint if changing sector .
903   $self->api_modify_existing_accesspoint (
904     $sector_name,
905     $opt->{tower_name},
906     $opt->{sector_uprate_limit},
907     $opt->{sector_downrate_limit},
908     $location,
909   ) if $existing_sector_ap && $opt->{modify_existing};
910
911   #if sector does not exist as an access point create it.
912   $self->api_create_accesspoint(
913     $sector_name,
914     $opt->{sector_uprate_limit},
915     $opt->{sector_downrate_limit},
916     $location,
917   ) unless $existing_sector_ap;
918
919   # Attach newly created sector to it's tower.
920   $self->api_modify_accesspoint($sector_name, $opt->{tower_name}, $location) unless ($self->{'__saisei_error'} || $existing_sector_ap);
921
922   # set access point to existing one or newly created one.
923   my $accesspoint = $existing_sector_ap ? $existing_sector_ap : $self->api_get_accesspoint($sector_name);
924
925   return { error => $self->api_error, } if $self->api_error;
926   return $accesspoint;
927 }
928
929 =head2 require_tower_and_sector
930
931 sets whether the service export requires a sector with it's tower.
932
933 =cut
934
935 sub require_tower_and_sector {
936   1;
937 }
938
939 =head2 tower_sector_required_fields
940
941 required fields needed for tower and sector export.
942
943 =cut
944
945 sub tower_sector_required_fields {
946   my $fields = {
947     'tower' => {
948       'up_rate_limit'   => '1',
949       'down_rate_limit' => '1',
950     },
951     'sector' => {
952       'up_rate_limit'   => '1',
953       'down_rate_limit' => '1',
954       'ip_addr'         => '1',
955     },
956   };
957   return $fields;
958 }
959
960 sub required_fields {
961   my @fields = ('svc_broadband__ip_addr_required', 'svc_broadband__speed_up_required', 'svc_broadband__speed_down_required', 'svc_broadband__sectornum_required');
962   return @fields;
963 }
964
965 sub process_virtual_ap {
966   my ($self, $opt) = @_;
967
968   my $existing_virtual_ap;
969   my $virtual_name = $opt->{virtual_name};
970
971   #check if virtual_ap has been set up as an access point.
972   $existing_virtual_ap = $self->api_get_accesspoint($virtual_name);
973
974   # modify the existing virtual accesspoint if changing it. this should never happen
975   $self->api_modify_existing_accesspoint (
976     $virtual_name,
977     $opt->{sector_name},
978     $opt->{virtual_uprate_limit},
979     $opt->{virtual_downrate_limit},
980   ) if $existing_virtual_ap && $opt->{modify_existing};
981
982   #if virtual ap does not exist as an access point create it.
983   $self->api_create_accesspoint(
984     $virtual_name,
985     $opt->{virtual_uprate_limit},
986     $opt->{virtual_downrate_limit},
987   ) unless $existing_virtual_ap;
988
989   my $update_sector;
990   if ($existing_virtual_ap && (ref $existing_virtual_ap->{collection}->[0]->{uplink} eq "HASH") && ($existing_virtual_ap->{collection}->[0]->{uplink}->{link}->{name} ne $opt->{sector_name})) {
991     $update_sector = 1;
992   }
993
994   # Attach newly created virtual ap to tower sector ap or if sector has changed.
995   $self->api_modify_accesspoint($virtual_name, $opt->{sector_name}) unless ($self->{'__saisei_error'} || ($existing_virtual_ap && !$update_sector));
996
997   # set access point to existing one or newly created one.
998   my $accesspoint = $existing_virtual_ap ? $existing_virtual_ap : $self->api_get_accesspoint($virtual_name);
999
1000   return $accesspoint;
1001 }
1002
1003 sub export_provisioned_services {
1004   my $job = shift;
1005   my $param = thaw(decode_base64(shift));
1006
1007   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1008   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1009   bless $part_export;
1010
1011   my @svcparts = FS::Record::qsearch({
1012     'table' => 'export_svc',
1013     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart  ) ',
1014     'hashref'   => { 'exportnum' => $param->{export_provisioned_services_exportnum}, },
1015   });
1016   my $part_count = scalar @svcparts;
1017
1018   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1019
1020   my @svcs = FS::Record::qsearch({
1021     'table' => 'cust_svc',
1022     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1023     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1024   }) unless !$parts;
1025
1026   my $svc_count = scalar @svcs;
1027
1028   my %status = {};
1029   for (my $c=1; $c <=100; $c=$c+1) { $status{int($svc_count * ($c/100))} = $c; }
1030
1031   my $process_count=0;
1032   foreach my $svc (@svcs) {
1033     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1034     ## check if service exists as host if not export it.
1035     my $host = api_get_host($part_export, $svc->{Hash}->{ip_addr});
1036     die ("Please double check your credentials as ".$host->{message}."\n") if $host->{message};
1037     warn "Exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1038     my $export_error = _export_insert($part_export,$svc) unless $host->{collection};
1039     if ($export_error) {
1040       warn "Error exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1041       die ("$export_error\n");
1042     }
1043     $process_count++;
1044   }
1045
1046   return;
1047
1048 }
1049
1050 sub export_all_towers_sectors {
1051   my $job = shift;
1052   my $param = shift;
1053
1054   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1055   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1056   bless $part_export;
1057
1058   my @towers = FS::Record::qsearch({
1059     'table' => 'tower',
1060   });
1061   my $tower_count = scalar @towers;
1062
1063   my %status = {};
1064   for (my $c=1; $c <=100; $c=$c+1) { $status{int($tower_count * ($c/100))} = $c; }
1065
1066   my $process_count=0;
1067   foreach my $tower (@towers) {
1068     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1069     my $export_error = export_tower_sector($part_export,$tower);
1070     if ($export_error->{'error'}) {
1071       warn "Error exporting tower/sector (".$tower->{Hash}->{towername}.")\n" if ($part_export->option('debug'));
1072       die ($export_error->{'error'}."\n");
1073     }
1074     $process_count++;
1075   }
1076
1077   return;
1078
1079 }
1080
1081 sub test_export_report {
1082   my ($self, $opts) = @_;
1083   my @export_error;
1084
1085   ##  check all part services for export errors
1086   my @exports = FS::Record::qsearch('part_export', { 'exporttype' => "saisei", } );
1087   my $export_nums = join "', '", map { $_->{Hash}->{exportnum} } @exports;
1088
1089   my $svc_part_export_error;
1090   my @svcparts = FS::Record::qsearch({
1091     'table' => 'part_svc',
1092     'addl_from' => 'LEFT JOIN export_svc USING ( svcpart  ) ',
1093     'extra_sql' => " WHERE export_svc.exportnum in ('".$export_nums."')",
1094   });
1095   my $part_count = scalar @svcparts;
1096
1097   my $svc_part_error;
1098   foreach (@svcparts) {
1099     my $part_error->{'description'} = $_->svc;
1100     $part_error->{'link'} = $opts->{'fsurl'}."/edit/part_svc.cgi?".$_->svcpart;
1101
1102     foreach my $s ('speed_up', 'speed_down') {
1103       my $speed = $_->part_svc_column($s);
1104       if ($speed->columnflag eq "" || $speed->columnflag eq "D") {
1105         $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is not set to be required and can be set while provisioning the service." unless $speed->required eq "Y";
1106       }
1107       elsif ($speed->columnflag eq "F" || $speed->columnflag eq "S") {
1108         $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is set to auto fill while provisioning the service but there is no value set." unless $speed->columnvalue;
1109       }
1110       elsif ($speed->columnflag eq "P") {
1111         my $fcc_speed_name = "broadband_".$speed->columnvalue."stream";
1112         foreach my $part_pkg ( FS::Record::qsearchs({
1113                                  'table'   => 'part_pkg',
1114                                  'addl_from' => 'LEFT JOIN pkg_svc USING ( pkgpart  ) ',
1115                                  'extra_sql' => " WHERE pkg_svc.svcpart = ".$_->svcpart,
1116                               })) {
1117           my $pkglink = '<a href="'.$opts->{'fsurl'}.'/edit/part_pkg.cgi?'.$part_pkg->pkgpart.'"><FONT COLOR="red"><B>'.$part_pkg->pkg.'</B></FONT></a>';
1118           $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is set to package FCC 477 information, but package ".$pkglink." does not have FCC ".$fcc_speed_name." set."
1119             unless $part_pkg->fcc_option($fcc_speed_name);
1120         }
1121       }
1122     }
1123     $part_error->{'errors'}->{'ip_addr'}    = "Field IP Address is not set to required" if $_->part_svc_column("ip_addr")->required ne "Y";
1124     $svc_part_error->{$_->svcpart} = $part_error if $part_error->{'errors'};
1125   }
1126
1127   $svc_part_export_error->{"services"}->{'description'} = "Service definitions";
1128   $svc_part_export_error->{"services"}->{'count'} = $part_count;
1129   $svc_part_export_error->{"services"}->{'errors'} = $svc_part_error if $svc_part_error;
1130
1131   push @export_error, $svc_part_export_error;
1132
1133   ##  check all provisioned cust services for export errors
1134   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1135   my $cust_svc_export_error;
1136   my @svcs = FS::Record::qsearch({
1137     'table' => 'cust_svc',
1138     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1139     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1140   }) unless !$parts;
1141   my $svc_count = scalar @svcs;
1142
1143   my $cust_svc_error;
1144   foreach (@svcs) {
1145     my $svc_error->{'description'} = $_->description;
1146     $svc_error->{'link'} = $opts->{'fsurl'}."/edit/svc_broadband.cgi?".$_->svcnum;
1147
1148     foreach my $s ('speed_up', 'speed_down', 'ip_addr') {
1149         $svc_error->{'errors'}->{$s} = "Field ".$s." is not set and is required for this service to be exported to Saisei." unless $_->$s;
1150     }
1151
1152     my $sector = FS::Record::qsearchs({
1153         'table' => 'tower_sector',
1154         'extra_sql' => " WHERE sectornum = ".$_->sectornum." AND sectorname != '_default'",
1155     }) if $_->sectornum;
1156     if (!$sector) {
1157       $svc_error->{'errors'}->{'sectornum'} = "No tower sector is set for this service. There needs to be a tower and sector set to be exported to Saisei.";
1158     }
1159     else {
1160       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1161         $svc_error->{'errors'}->{'sectornum'} = "The sector ".$sector->description." does not have a ".$s." set. The sector needs a ".$s." set to be exported to Saisei."
1162           unless $sector->$s;
1163       }
1164     }
1165     $cust_svc_error->{$_->svcnum} = $svc_error if $svc_error->{'errors'};
1166   }
1167
1168   $cust_svc_export_error->{"provisioned_services"}->{'description'} = "Provisioned services";
1169   $cust_svc_export_error->{"provisioned_services"}->{'count'} = $svc_count;
1170   $cust_svc_export_error->{"provisioned_services"}->{'errors'} = $cust_svc_error if $cust_svc_error;
1171
1172   push @export_error, $cust_svc_export_error;
1173
1174
1175   ##  check all towers and sectors for export errors
1176   my $tower_sector_export_error;
1177   my @towers = FS::Record::qsearch({
1178     'table' => 'tower',
1179   });
1180   my $tower_count = scalar @towers;
1181
1182   my $towers_error;
1183   foreach (@towers) {
1184     my $tower_error->{'description'} = $_->towername;
1185     $tower_error->{'link'} = $opts->{'fsurl'}."/edit/tower.html?".$_->towernum;
1186
1187     foreach my $s ('up_rate_limit', 'down_rate_limit') {
1188         $tower_error->{'errors'}->{$s} = "Field ".$s." is not set for the tower, this is required for this tower to be exported to Saisei." unless $_->$s;
1189     }
1190
1191     my @sectors = FS::Record::qsearch({
1192         'table' => 'tower_sector',
1193         'extra_sql' => " WHERE towernum = ".$_->towernum." AND sectorname != '_default' AND (up_rate_limit IS NULL OR down_rate_limit IS NULL)",
1194     }) if $_->towernum;
1195     foreach my $sector (@sectors) {
1196       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1197         $tower_error->{'errors'}->{'sector_'.$s} = "The sector ".$sector->description." does not have a ".$s." set. The sector needs a ".$s." set to be exported to Saisei."
1198           if !$sector->$s;
1199       }
1200     }
1201     $towers_error->{$_->towernum} = $tower_error if $tower_error->{'errors'};
1202   }
1203
1204   $tower_sector_export_error->{"tower_sector"}->{'description'} = "Tower / Sector";
1205   $tower_sector_export_error->{"tower_sector"}->{'count'} = $tower_count;
1206   $tower_sector_export_error->{"tower_sector"}->{'errors'} = $towers_error if $towers_error;
1207
1208   push @export_error, $tower_sector_export_error;
1209
1210   return [@export_error];
1211
1212 }
1213
1214 =head1 SEE ALSO
1215
1216 L<FS::part_export>
1217
1218 =cut
1219
1220 1;