RT83450 - V3 fix for tower/sector 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
353   my $tower_opt = {
354     'tower_name'           => $tower_name,
355     'tower_num'            => $tower->{Hash}->{towernum},
356     'tower_uprate_limit'   => $tower->{Hash}->{up_rate_limit},
357     'tower_downrate_limit' => $tower->{Hash}->{down_rate_limit},
358     'modify_existing'      => '1', # modify an existing access point with this info
359   };
360   $tower_opt->{'location'} = $tower_location if $tower_location;
361
362   my $tower_access_point = process_tower($self, $tower_opt);
363     return $tower_access_point if $tower_access_point->{error};
364
365   #get list of all access points
366   my $hash_opt = {
367       'table'     => 'tower_sector',
368       'select'    => '*',
369       'hashref'   => { 'towernum' => $tower->{Hash}->{towernum}, },
370   };
371
372   #for each one modify or create it.
373   foreach my $tower_sector ( FS::Record::qsearch($hash_opt) ) {
374     next if $tower_sector->{Hash}->{sectorname} eq "_default";
375     my $sector_name = $tower_sector->{Hash}->{sectorname};
376     $sector_name =~ s/\s/_/g;
377     my $sector_opt = {
378       'tower_name'            => $tower_name,
379       'tower_num'             => $tower_sector->{Hash}->{towernum},
380       'sector_name'           => $sector_name,
381       'sector_uprate_limit'   => $tower_sector->{Hash}->{up_rate_limit},
382       'sector_downrate_limit' => $tower_sector->{Hash}->{down_rate_limit},
383       'modify_existing'       => '1', # modify an existing access point with this info
384     };
385     $sector_opt->{'location'} = $tower_location if $tower_location;
386
387     my $sector_access_point = process_sector($self, $sector_opt) unless ($sector_name eq "_default");
388       return $sector_access_point if $sector_access_point->{error};
389   }
390
391   return { error => $self->api_error, };
392 }
393
394 ## creates the rateplan name
395 sub get_rateplan_name {
396   my ($self, $svc_broadband, $svc_name) = @_;
397
398   my $service_part = FS::Record::qsearchs( 'part_svc', { 'svcpart' => $svc_broadband->{Hash}->{svcpart} } ) unless $svc_name;
399   my $service_name = $svc_name ? $svc_name : $service_part->{Hash}->{svc};
400
401   my $rateplan_name = $service_name . " " . $svc_broadband->{Hash}->{speed_down} . "-" . $svc_broadband->{Hash}->{speed_up};
402   $rateplan_name =~ s/\s/_/g; $rateplan_name =~ s/[^A-Za-z0-9\-_]//g;
403
404   return $rateplan_name;
405 }
406
407 =head1 Saisei API
408
409 These methods allow access to the Saisei API using the credentials
410 set in the export options.
411
412 =cut
413
414 =head2 api_call
415
416 Accepts I<$method>, I<$path>, I<$params> hashref and optional.
417 Places an api call to the specified path and method with the specified params.
418 Returns the decoded json object returned by the api call.
419 Returns empty on failure;  retrieve error messages using L</api_error>.
420
421 =cut
422
423 sub api_call {
424   my ($self,$method,$path,$params) = @_;
425
426   $self->{'__saisei_error'} = '';
427   my $auth_info = $self->option('username') . ':' . $self->option('password');
428   $params ||= {};
429
430   warn "Calling $method on http://"
431     .$self->{Hash}->{machine}.':'.$self->option('port')
432     ."/rest/top/configurations/running/$path\n" if $self->option('debug');
433
434   my $data = encode_json($params) if keys %{ $params };
435
436   my $client = REST::Client->new();
437   $client->addHeader("Authorization", "Basic ".encode_base64($auth_info));
438   $client->setHost('http://'.$self->{Hash}->{machine}.':'.$self->option('port'));
439   $client->$method('/rest/top/configurations/running'.$path, $data, { "Content-type" => 'application/json'});
440
441   warn "Saisei Response Code is ".$client->responseCode()."\n" if $self->option('debug');
442
443   my $result;
444
445   if ($client->responseCode() eq '200' || $client->responseCode() eq '201') {
446     eval { $result = decode_json($client->responseContent()) };
447     unless ($result) {
448       $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.";
449       warn "Saisei RC 201 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
450       return;
451     }
452   }
453   elsif ($client->responseCode() eq '404') {
454     eval { $result = decode_json($client->responseContent()) };
455     unless ($result) {
456       $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.";
457       warn "Saisei RC 404 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
458       return;
459     }
460     ## check if message is for empty hash.
461     my($does_not_exist) = $result->{message} =~ /'(.*)' does not exist$/;
462     $self->{'__saisei_error'} = "Saisei Error: ".$result->{message} unless $does_not_exist;
463     warn "Saisei Response Content is\n".$client->responseContent."\n" if ($self->option('debug') && !$does_not_exist);
464     return;
465   }
466   elsif ($client->responseCode() eq '500') {
467     $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();
468     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
469     return;
470   }
471   else {
472     $self->{'__saisei_error'} = "Received Bad response from server during $method $path $data, we received responce code: " . $client->responseCode() . " " . $client->responseContent;
473     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
474     return; 
475   }
476
477   return $result;
478   
479 }
480
481 =head2 api_error
482
483 Returns the error string set by L</Saisei API> methods,
484 or a blank string if most recent call produced no errors.
485
486 =cut
487
488 sub api_error {
489   my $self = shift;
490   return $self->{'__saisei_error'} || '';
491 }
492
493 =head2 api_get_policies
494
495 Gets a list of global policies.
496
497 =cut
498
499 sub api_get_policies {
500   my $self = shift;
501
502   my $get_policies = $self->api_call("GET", '/policies/?token=1&order=name&start=0&limit=20&select=name%2Cpercent_rate%2Cassured%2C');
503   return if $self->api_error;
504   $self->{'__saisei_error'} = "Did not receive any global policies from Saisei."
505     unless $get_policies;
506
507   return $get_policies->{collection};
508 }
509
510 =head2 api_get_rateplan
511
512 Gets rateplan info for specific rateplan.
513
514 =cut
515
516 sub api_get_rateplan {
517   my $self = shift;
518   my $rateplan = shift;
519
520   my $get_rateplan = $self->api_call("GET", "/rate_plans/$rateplan");
521   return if $self->api_error;
522
523   return $get_rateplan;
524 }
525
526 =head2 api_get_user
527
528 Gets user info for specific user.
529
530 =cut
531
532 sub api_get_user {
533   my $self = shift;
534   my $user = shift;
535
536   my $get_user = $self->api_call("GET", "/users/$user");
537   return if $self->api_error;
538
539   return $get_user;
540 }
541
542 =head2 api_get_accesspoint
543
544 Gets user info for specific access point.
545
546 =cut
547
548 sub api_get_accesspoint {
549   my $self = shift;
550   my $accesspoint = shift;
551
552   my $get_accesspoint = $self->api_call("GET", "/access_points/$accesspoint");
553   return if $self->api_error;
554
555   return $get_accesspoint;
556 }
557
558 =head2 api_get_host
559
560 Gets user info for specific host.
561
562 =cut
563
564 sub api_get_host {
565   my $self = shift;
566   my $ip = shift;
567
568   my $get_host = $self->api_call("GET", "/hosts/$ip");
569
570   return { message => $self->api_error, } if $self->api_error;
571
572   return $get_host;
573 }
574
575 =head2 api_create_rateplan
576
577 Creates a rateplan.
578
579 =cut
580
581 sub api_create_rateplan {
582   my ($self, $svc, $rateplan) = @_;
583
584   $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};
585   $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};
586
587   my $new_rateplan = $self->api_call(
588       "PUT", 
589       "/rate_plans/$rateplan",
590       {
591         'downstream_rate' => $svc->{Hash}->{speed_down},
592         'upstream_rate' => $svc->{Hash}->{speed_up},
593       },
594   ) unless $self->{'__saisei_error'};
595
596   $self->{'__saisei_error'} = "Saisei could not create the rate plan $rateplan."
597     unless ($new_rateplan || $self->{'__saisei_error'});
598
599   return $new_rateplan;
600
601 }
602
603 =head2 api_modify_rateplan
604
605 Modify a new rateplan.
606
607 =cut
608
609 sub api_modify_rateplan {
610   my ($self,$svc,$rateplan_name) = @_;
611
612   # get policy list
613   my $policies = $self->api_get_policies();
614
615   foreach my $policy (@$policies) {
616     my $policyname = $policy->{name};
617     my $rate_multiplier = '';
618     if ($policy->{background}) { $rate_multiplier = ".01"; }
619     my $modified_rateplan = $self->api_call(
620       "PUT", 
621       "/rate_plans/$rateplan_name/partitions/$policyname",
622       {
623         'restricted'      =>  $policy->{assured},         # policy_assured_flag
624         'rate_multiplier' => $rate_multiplier,           # policy_background 0.1
625         'rate'            =>  $policy->{percent_rate}, # policy_percent_rate
626       },
627     );
628
629     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name after it was created."
630       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
631     
632   }
633
634   return;
635  
636 }
637
638 =head2 api_modify_existing_rateplan
639
640 Modify a existing rateplan.
641
642 =cut
643
644 sub api_modify_existing_rateplan {
645   my ($self,$svc,$rateplan_name) = @_;
646
647   $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};
648   $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};
649
650   my $modified_rateplan = $self->api_call(
651     "PUT",
652     "/rate_plans/$rateplan_name",
653     {
654       'downstream_rate' => $svc->{Hash}->{speed_down},
655       'upstream_rate' => $svc->{Hash}->{speed_up},
656     },
657   );
658
659     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name."
660       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
661
662   return;
663
664 }
665
666 =head2 api_create_user
667
668 Creates a user.
669
670 =cut
671
672 sub api_create_user {
673   my ($self,$user, $description, $location) = @_;
674
675   my $user_hash = {
676     'description' => $description,
677   };
678   $user_hash->{'map_location'} = $location if $location;
679
680   my $new_user = $self->api_call(
681       "PUT", 
682       "/users/$user",
683       $user_hash,
684   );
685
686   $self->{'__saisei_error'} = "Saisei could not create the user $user"
687     unless ($new_user || $self->{'__saisei_error'}); # should never happen
688
689   return $new_user;
690
691 }
692
693 =head2 api_create_accesspoint
694
695 Creates a access point.
696
697 =cut
698
699 sub api_create_accesspoint {
700   my ($self,$accesspoint, $upratelimit, $downratelimit, $location) = @_;
701
702   my $ap_hash = {
703     'downstream_rate_limit' => $downratelimit,
704     'upstream_rate_limit'   => $upratelimit,
705     'interface'             => $self->option('interface'),
706   };
707   $ap_hash->{'map_location'} = $location if $location;
708
709   my $new_accesspoint = $self->api_call(
710       "PUT",
711       "/access_points/$accesspoint",
712       $ap_hash,
713   );
714
715   $self->{'__saisei_error'} = "Saisei could not create the access point $accesspoint"
716     unless ($new_accesspoint || $self->{'__saisei_error'}); # should never happen
717   return;
718
719 }
720
721 =head2 api_modify_accesspoint
722
723 Modify a new access point.
724
725 =cut
726
727 sub api_modify_accesspoint {
728   my ($self, $accesspoint, $uplink, $location) = @_;
729
730   my $ap_hash = {
731     'uplink'    => $uplink,
732     'interface' => $self->option('interface'),
733   };
734   $ap_hash->{'map_location'} = $location if $location;
735
736   my $modified_accesspoint = $self->api_call(
737     "PUT",
738     "/access_points/$accesspoint",
739     $ap_hash,
740   );
741
742   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint after it was created."
743     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
744
745   return;
746
747 }
748
749 =head2 api_modify_existing_accesspoint
750
751 Modify a existing accesspoint.
752
753 =cut
754
755 sub api_modify_existing_accesspoint {
756   my ($self, $accesspoint, $uplink, $upratelimit, $downratelimit, $location) = @_;
757
758   my $ap_hash = {
759     'downstream_rate_limit' => $downratelimit,
760     'upstream_rate_limit'   => $upratelimit,
761     'interface'             => $self->option('interface'),
762 #   'uplink'                => $uplink, # name of attached access point
763   };
764   $ap_hash->{'map_location'} = $location if $location;
765
766   my $modified_accesspoint = $self->api_call(
767     "PUT",
768     "/access_points/$accesspoint",
769     $ap_hash,
770   );
771
772   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint."
773     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
774
775   return;
776
777 }
778
779 =head2 api_add_host_to_user
780
781 ties host to user, rateplan and default access point.
782
783 =cut
784
785 sub api_add_host_to_user {
786 #  my ($self,$user, $rateplan, $ip, $accesspoint, $location) = @_;
787   my ($self,$opt) = @_;
788   my $ip = $opt->{'ip'};
789   my $location = $opt->{'location'};
790
791   my $newhost_hash = {
792     'user'         => $opt->{'user'},
793     'rate_plan'    => $opt->{'rateplan'},
794     'access_point' => $opt->{'accesspoint'},
795   };
796   $newhost_hash->{'map_location'} = $location if $location;
797
798   my $new_host = $self->api_call(
799       "PUT", 
800       "/hosts/$ip",
801       $newhost_hash,
802   );
803
804   $self->{'__saisei_error'} = "Saisei could not create the host $ip"
805     unless ($new_host || $self->{'__saisei_error'}); # should never happen
806
807   return $new_host;
808
809 }
810
811 =head2 api_delete_host_to_user
812
813 unties host from user and rateplan.
814 this will set the host entry at Saisei to the default rate plan with the user and access point set to <none>.
815
816 =cut
817
818 sub api_delete_host_to_user {
819   my ($self,$user, $rateplan, $ip) = @_;
820
821   my $default_rate_plan = $self->api_call("GET", '?token=1&select=default_rate_plan');
822     return if $self->api_error;
823   $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."
824     unless $default_rate_plan;
825
826   my $default_rateplan_name = $default_rate_plan->{collection}->[0]->{default_rate_plan}->{link}->{name};
827
828   my $delete_host = $self->api_call(
829       "PUT",
830       "/hosts/$ip",
831       {
832         'user'          => '<none>',
833         'access_point'  => '<none>',
834         'rate_plan'     => $default_rateplan_name,
835       },
836   );
837
838   $self->{'__saisei_error'} = "Saisei could not delete the host $ip"
839     unless ($delete_host || $self->{'__saisei_error'}); # should never happen
840
841   return $delete_host;
842
843 }
844
845 sub process_tower {
846   my ($self, $opt) = @_;
847
848   if (!$opt->{tower_uprate_limit} || !$opt->{tower_downrate_limit}) {
849     $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.";
850     return { error => $self->api_error, };
851   }
852
853   my $existing_tower_ap;
854   my $tower_name = $opt->{tower_name};
855   my $location = $opt->{location};
856
857   #check if tower has been set up as an access point.
858   $existing_tower_ap = $self->api_get_accesspoint($tower_name) unless $self->{'__saisei_error'};
859
860   # modify the existing accesspoint if changing tower .
861   $self->api_modify_existing_accesspoint (
862     $tower_name,
863     '', # tower does not have a uplink on sectors.
864     $opt->{tower_uprate_limit},
865     $opt->{tower_downrate_limit},
866     $location,
867   ) if $existing_tower_ap->{collection} && $opt->{modify_existing};
868
869   #if tower does not exist as an access point create it.
870   $self->api_create_accesspoint(
871       $tower_name,
872       $opt->{tower_uprate_limit},
873       $opt->{tower_downrate_limit},
874       $location,
875   ) unless $existing_tower_ap->{collection};
876
877   my $accesspoint = $self->api_get_accesspoint($tower_name);
878
879   return { error => $self->api_error, } if $self->api_error;
880   return $accesspoint;
881 }
882
883 sub process_sector {
884   my ($self, $opt) = @_;
885
886   if (!$opt->{sector_name} || $opt->{sector_name} eq '_default') {
887     $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.";
888     return { error => $self->api_error, };
889   }
890
891   if (!$opt->{sector_uprate_limit} || !$opt->{sector_downrate_limit}) {
892     $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.";
893     return { error => $self->api_error, };
894   }
895
896   my $existing_sector_ap;
897   my $sector_name = $opt->{sector_name};
898   my $location = $opt->{location};
899
900   #check if sector has been set up as an access point.
901   $existing_sector_ap = $self->api_get_accesspoint($sector_name);
902
903   # modify the existing accesspoint if changing sector .
904   $self->api_modify_existing_accesspoint (
905     $sector_name,
906     $opt->{tower_name},
907     $opt->{sector_uprate_limit},
908     $opt->{sector_downrate_limit},
909     $location,
910   ) if $existing_sector_ap && $opt->{modify_existing};
911
912   #if sector does not exist as an access point create it.
913   $self->api_create_accesspoint(
914     $sector_name,
915     $opt->{sector_uprate_limit},
916     $opt->{sector_downrate_limit},
917     $location,
918   ) unless $existing_sector_ap;
919
920   # Attach newly created sector to it's tower.
921   $self->api_modify_accesspoint($sector_name, $opt->{tower_name}, $location) unless ($self->{'__saisei_error'} || $existing_sector_ap);
922
923   # set access point to existing one or newly created one.
924   my $accesspoint = $existing_sector_ap ? $existing_sector_ap : $self->api_get_accesspoint($sector_name);
925
926   return { error => $self->api_error, } if $self->api_error;
927   return $accesspoint;
928 }
929
930 =head2 require_tower_and_sector
931
932 sets whether the service export requires a sector with it's tower.
933
934 =cut
935
936 sub require_tower_and_sector {
937   1;
938 }
939
940 =head2 tower_sector_required_fields
941
942 required fields needed for tower and sector export.
943
944 =cut
945
946 sub tower_sector_required_fields {
947   my $fields = {
948     'tower' => {
949       'up_rate_limit'   => '1',
950       'down_rate_limit' => '1',
951     },
952     'sector' => {
953       'up_rate_limit'   => '1',
954       'down_rate_limit' => '1',
955       'ip_addr'         => '1',
956     },
957   };
958   return $fields;
959 }
960
961 sub required_fields {
962   my @fields = ('svc_broadband__ip_addr_required', 'svc_broadband__speed_up_required', 'svc_broadband__speed_down_required', 'svc_broadband__sectornum_required');
963   return @fields;
964 }
965
966 sub process_virtual_ap {
967   my ($self, $opt) = @_;
968
969   my $existing_virtual_ap;
970   my $virtual_name = $opt->{virtual_name};
971
972   #check if virtual_ap has been set up as an access point.
973   $existing_virtual_ap = $self->api_get_accesspoint($virtual_name);
974
975   # modify the existing virtual accesspoint if changing it. this should never happen
976   $self->api_modify_existing_accesspoint (
977     $virtual_name,
978     $opt->{sector_name},
979     $opt->{virtual_uprate_limit},
980     $opt->{virtual_downrate_limit},
981   ) if $existing_virtual_ap && $opt->{modify_existing};
982
983   #if virtual ap does not exist as an access point create it.
984   $self->api_create_accesspoint(
985     $virtual_name,
986     $opt->{virtual_uprate_limit},
987     $opt->{virtual_downrate_limit},
988   ) unless $existing_virtual_ap;
989
990   my $update_sector;
991   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})) {
992     $update_sector = 1;
993   }
994
995   # Attach newly created virtual ap to tower sector ap or if sector has changed.
996   $self->api_modify_accesspoint($virtual_name, $opt->{sector_name}) unless ($self->{'__saisei_error'} || ($existing_virtual_ap && !$update_sector));
997
998   # set access point to existing one or newly created one.
999   my $accesspoint = $existing_virtual_ap ? $existing_virtual_ap : $self->api_get_accesspoint($virtual_name);
1000
1001   return $accesspoint;
1002 }
1003
1004 sub export_provisioned_services {
1005   my $job = shift;
1006   my $param = thaw(decode_base64(shift));
1007
1008   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1009   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1010   bless $part_export;
1011
1012   my @svcparts = FS::Record::qsearch({
1013     'table' => 'export_svc',
1014     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart  ) ',
1015     'hashref'   => { 'exportnum' => $param->{export_provisioned_services_exportnum}, },
1016   });
1017   my $part_count = scalar @svcparts;
1018
1019   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1020
1021   my @svcs = FS::Record::qsearch({
1022     'table' => 'cust_svc',
1023     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1024     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1025   }) unless !$parts;
1026
1027   my $svc_count = scalar @svcs;
1028
1029   my %status = {};
1030   for (my $c=1; $c <=100; $c=$c+1) { $status{int($svc_count * ($c/100))} = $c; }
1031
1032   my $process_count=0;
1033   foreach my $svc (@svcs) {
1034     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1035     ## check if service exists as host if not export it.
1036     my $host = api_get_host($part_export, $svc->{Hash}->{ip_addr});
1037     die ("Please double check your credentials as ".$host->{message}."\n") if $host->{message};
1038     warn "Exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1039     my $export_error = _export_insert($part_export,$svc) unless $host->{collection};
1040     if ($export_error) {
1041       warn "Error exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1042       die ("$export_error\n");
1043     }
1044     $process_count++;
1045   }
1046
1047   return;
1048
1049 }
1050
1051 sub export_all_towers_sectors {
1052   my $job = shift;
1053   my $param = thaw(decode_base64(shift));
1054
1055   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1056   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1057   bless $part_export;
1058
1059   my @towers = FS::Record::qsearch({
1060     'table' => 'tower',
1061   });
1062   my $tower_count = scalar @towers;
1063
1064   my %status = {};
1065   for (my $c=1; $c <=100; $c=$c+1) { $status{int($tower_count * ($c/100))} = $c; }
1066
1067   my $process_count=0;
1068   foreach my $tower (@towers) {
1069     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1070     my $export_error = export_tower_sector($part_export,$tower);
1071     if ($export_error->{'error'}) {
1072       warn "Error exporting tower/sector (".$tower->{Hash}->{towername}.")\n" if ($part_export->option('debug'));
1073       die ($export_error->{'error'}."\n");
1074     }
1075     $process_count++;
1076   }
1077
1078   return;
1079
1080 }
1081
1082 sub test_export_report {
1083   my ($self, $opts) = @_;
1084   my @export_error;
1085
1086   ##  check all part services for export errors
1087   my @exports = FS::Record::qsearch('part_export', { 'exporttype' => "saisei", } );
1088   my $export_nums = join "', '", map { $_->{Hash}->{exportnum} } @exports;
1089
1090   my $svc_part_export_error;
1091   my @svcparts = FS::Record::qsearch({
1092     'table' => 'part_svc',
1093     'addl_from' => 'LEFT JOIN export_svc USING ( svcpart  ) ',
1094     'extra_sql' => " WHERE export_svc.exportnum in ('".$export_nums."')",
1095   });
1096   my $part_count = scalar @svcparts;
1097
1098   my $svc_part_error;
1099   foreach (@svcparts) {
1100     my $part_error->{'description'} = $_->svc;
1101     $part_error->{'link'} = $opts->{'fsurl'}."/edit/part_svc.cgi?".$_->svcpart;
1102
1103     foreach my $s ('speed_up', 'speed_down') {
1104       my $speed = $_->part_svc_column($s);
1105       if ($speed->columnflag eq "" || $speed->columnflag eq "D") {
1106         $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";
1107       }
1108       elsif ($speed->columnflag eq "F" || $speed->columnflag eq "S") {
1109         $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;
1110       }
1111       elsif ($speed->columnflag eq "P") {
1112         my $fcc_speed_name = "broadband_".$speed->columnvalue."stream";
1113         foreach my $part_pkg ( FS::Record::qsearchs({
1114                                  'table'   => 'part_pkg',
1115                                  'addl_from' => 'LEFT JOIN pkg_svc USING ( pkgpart  ) ',
1116                                  'extra_sql' => " WHERE pkg_svc.svcpart = ".$_->svcpart,
1117                               })) {
1118           my $pkglink = '<a href="'.$opts->{'fsurl'}.'/edit/part_pkg.cgi?'.$part_pkg->pkgpart.'"><FONT COLOR="red"><B>'.$part_pkg->pkg.'</B></FONT></a>';
1119           $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."
1120             unless $part_pkg->fcc_option($fcc_speed_name);
1121         }
1122       }
1123     }
1124     $part_error->{'errors'}->{'ip_addr'}    = "Field IP Address is not set to required" if $_->part_svc_column("ip_addr")->required ne "Y";
1125     $svc_part_error->{$_->svcpart} = $part_error if $part_error->{'errors'};
1126   }
1127
1128   $svc_part_export_error->{"services"}->{'description'} = "Service definitions";
1129   $svc_part_export_error->{"services"}->{'count'} = $part_count;
1130   $svc_part_export_error->{"services"}->{'errors'} = $svc_part_error if $svc_part_error;
1131
1132   push @export_error, $svc_part_export_error;
1133
1134   ##  check all provisioned cust services for export errors
1135   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1136   my $cust_svc_export_error;
1137   my @svcs = FS::Record::qsearch({
1138     'table' => 'cust_svc',
1139     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1140     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1141   }) unless !$parts;
1142   my $svc_count = scalar @svcs;
1143
1144   my $cust_svc_error;
1145   foreach (@svcs) {
1146     my $svc_error->{'description'} = $_->description;
1147     $svc_error->{'link'} = $opts->{'fsurl'}."/edit/svc_broadband.cgi?".$_->svcnum;
1148
1149     foreach my $s ('speed_up', 'speed_down', 'ip_addr') {
1150         $svc_error->{'errors'}->{$s} = "Field ".$s." is not set and is required for this service to be exported to Saisei." unless $_->$s;
1151     }
1152
1153     my $sector = FS::Record::qsearchs({
1154         'table' => 'tower_sector',
1155         'extra_sql' => " WHERE sectornum = ".$_->sectornum." AND sectorname != '_default'",
1156     }) if $_->sectornum;
1157     if (!$sector) {
1158       $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.";
1159     }
1160     else {
1161       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1162         $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."
1163           unless $sector->$s;
1164       }
1165     }
1166     $cust_svc_error->{$_->svcnum} = $svc_error if $svc_error->{'errors'};
1167   }
1168
1169   $cust_svc_export_error->{"provisioned_services"}->{'description'} = "Provisioned services";
1170   $cust_svc_export_error->{"provisioned_services"}->{'count'} = $svc_count;
1171   $cust_svc_export_error->{"provisioned_services"}->{'errors'} = $cust_svc_error if $cust_svc_error;
1172
1173   push @export_error, $cust_svc_export_error;
1174
1175
1176   ##  check all towers and sectors for export errors
1177   my $tower_sector_export_error;
1178   my @towers = FS::Record::qsearch({
1179     'table' => 'tower',
1180   });
1181   my $tower_count = scalar @towers;
1182
1183   my $towers_error;
1184   foreach (@towers) {
1185     my $tower_error->{'description'} = $_->towername;
1186     $tower_error->{'link'} = $opts->{'fsurl'}."/edit/tower.html?".$_->towernum;
1187
1188     foreach my $s ('up_rate_limit', 'down_rate_limit') {
1189         $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;
1190     }
1191
1192     my @sectors = FS::Record::qsearch({
1193         'table' => 'tower_sector',
1194         'extra_sql' => " WHERE towernum = ".$_->towernum." AND sectorname != '_default' AND (up_rate_limit IS NULL OR down_rate_limit IS NULL)",
1195     }) if $_->towernum;
1196     foreach my $sector (@sectors) {
1197       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1198         $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."
1199           if !$sector->$s;
1200       }
1201     }
1202     $towers_error->{$_->towernum} = $tower_error if $tower_error->{'errors'};
1203   }
1204
1205   $tower_sector_export_error->{"tower_sector"}->{'description'} = "Tower / Sector";
1206   $tower_sector_export_error->{"tower_sector"}->{'count'} = $tower_count;
1207   $tower_sector_export_error->{"tower_sector"}->{'errors'} = $towers_error if $towers_error;
1208
1209   push @export_error, $tower_sector_export_error;
1210
1211   return [@export_error];
1212
1213 }
1214
1215 =head1 SEE ALSO
1216
1217 L<FS::part_export>
1218
1219 =cut
1220
1221 1;