+=back
+
+=cut
+
+=head1 CLASS METHODS
+
+=over 4
+
+=cut
+
+# _upgrade_data
+#
+# Used by FS::Upgrade to migrate to a new database.
+
+sub _upgrade_data { # class method
+ my($class, %opts) = @_;
+
+ warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
+
+ my @part_pkg = qsearch({
+ 'table' => 'part_pkg',
+ 'extra_sql' => "WHERE ". join(' OR ',
+ 'plan IS NULL', "plan = '' ",
+ ),
+ });
+
+ foreach my $part_pkg (@part_pkg) {
+
+ unless ( $part_pkg->plan ) {
+ $part_pkg->plan('flat');
+ }
+
+ $part_pkg->replace;
+
+ }
+
+ # Convert RADIUS accounting usage metrics from megabytes to gigabytes
+ # (FS RT#28105)
+ my $upgrade = 'part_pkg_gigabyte_usage';
+ if (!FS::upgrade_journal->is_done($upgrade)) {
+ foreach my $part_pkg (qsearch('part_pkg',
+ { plan => 'sqlradacct_hour' })
+ ){
+
+ my $pkgpart = $part_pkg->pkgpart;
+
+ foreach my $opt (qsearch('part_pkg_option',
+ { 'optionname' => { op => 'LIKE',
+ value => 'recur_included_%',
+ },
+ pkgpart => $pkgpart,
+ })){
+
+ next if $opt->optionname eq 'recur_included_hours'; # unfortunately named field
+ next if $opt->optionvalue == 0;
+
+ $opt->optionvalue($opt->optionvalue / 1024);
+
+ my $error = $opt->replace;
+ die $error if $error;
+ }
+
+ foreach my $opt (qsearch('part_pkg_option',
+ { 'optionname' => { op => 'LIKE',
+ value => 'recur_%_charge',
+ },
+ pkgpart => $pkgpart,
+ })){
+ $opt->optionvalue($opt->optionvalue * 1024);
+
+ my $error = $opt->replace;
+ die $error if $error;
+ }
+
+ }
+ FS::upgrade_journal->set_done($upgrade);
+ }
+
+ # the rest can be done asynchronously
+}
+
+sub queueable_upgrade {
+ # now upgrade to the explicit custom flag
+
+ my $search = FS::Cursor->new({
+ 'table' => 'part_pkg',
+ 'hashref' => { disabled => 'Y', custom => '' },
+ 'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
+ });
+ my $dbh = dbh;
+
+ while (my $part_pkg = $search->fetch) {
+ my $new = new FS::part_pkg { $part_pkg->hash };
+ $new->custom('Y');
+ my $comment = $part_pkg->comment;
+ $comment =~ s/^\(CUSTOM\) //;
+ $comment = '(none)' unless $comment =~ /\S/;
+ $new->comment($comment);
+
+ my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
+ my $primary = $part_pkg->svcpart;
+ my $options = { $part_pkg->options };
+
+ my $error = $new->replace( $part_pkg,
+ 'pkg_svc' => $pkg_svc,
+ 'primary_svc' => $primary,
+ 'options' => $options,
+ );
+ if ($error) {
+ warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+ $dbh->rollback;
+ } else {
+ $dbh->commit;
+ }
+ }
+
+ # set family_pkgpart on any packages that don't have it
+ $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
+ while (my $part_pkg = $search->fetch) {
+ $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
+ my $error = $part_pkg->SUPER::replace;
+ if ($error) {
+ warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+ $dbh->rollback;
+ } else {
+ $dbh->commit;
+ }
+ }
+
+ my @part_pkg_option = qsearch('part_pkg_option',
+ { 'optionname' => 'unused_credit',
+ 'optionvalue' => 1,
+ });
+ foreach my $old_opt (@part_pkg_option) {
+ my $pkgpart = $old_opt->pkgpart;
+ my $error = $old_opt->delete;
+ die $error if $error;
+
+ foreach (qw(unused_credit_cancel unused_credit_change)) {
+ my $new_opt = new FS::part_pkg_option {
+ 'pkgpart' => $pkgpart,
+ 'optionname' => $_,
+ 'optionvalue' => 1,
+ };
+ $error = $new_opt->insert;
+ die $error if $error;
+ }
+ }
+
+ # migrate use_disposition_taqua and use_disposition to disposition_in
+ @part_pkg_option = qsearch('part_pkg_option',
+ { 'optionname' => { op => 'LIKE',
+ value => 'use_disposition%',
+ },
+ 'optionvalue' => 1,
+ });
+ my %newopts = map { $_->pkgpart => $_ }
+ qsearch('part_pkg_option', { 'optionname' => 'disposition_in', } );
+ foreach my $old_opt (@part_pkg_option) {
+ my $pkgpart = $old_opt->pkgpart;
+ my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100'
+ : 'ANSWERED';
+ my $error = $old_opt->delete;
+ die $error if $error;
+
+ if ( exists($newopts{$pkgpart}) ) {
+ my $opt = $newopts{$pkgpart};
+ $opt->optionvalue($opt->optionvalue.",$newval");
+ $error = $opt->replace;
+ die $error if $error;
+ } else {
+ my $new_opt = new FS::part_pkg_option {
+ 'pkgpart' => $pkgpart,
+ 'optionname' => 'disposition_in',
+ 'optionvalue' => $newval,
+ };
+ $error = $new_opt->insert;
+ die $error if $error;
+ $newopts{$pkgpart} = $new_opt;
+ }
+ }
+
+ # set any package with FCC voice lines to the "VoIP with broadband" category
+ # for backward compatibility
+ #
+ # recover from a bad upgrade bug
+ my $upgrade = 'part_pkg_fcc_voip_class_FIX';
+ if (!FS::upgrade_journal->is_done($upgrade)) {
+ my $bad_upgrade = qsearchs('upgrade_journal',
+ { upgrade => 'part_pkg_fcc_voip_class' }
+ );
+ if ( $bad_upgrade ) {
+ my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
+ ' AND history_date > '.($bad_upgrade->_date - 3600);
+ my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
+ qsearch({
+ 'select' => '*',
+ 'table' => 'h_part_pkg_option',
+ 'hashref' => {},
+ 'extra_sql' => "$where AND history_action = 'delete'",
+ 'order_by' => 'ORDER BY history_date ASC',
+ });
+ my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
+ qsearch({
+ 'select' => '*',
+ 'table' => 'h_pkg_svc',
+ 'hashref' => {},
+ 'extra_sql' => "$where AND history_action = 'replace_old'",
+ 'order_by' => 'ORDER BY history_date ASC',
+ });
+ my %opt;
+ foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
+ my $pkgpart ||= $deleted->pkgpart;
+ $opt{$pkgpart} ||= {
+ options => {},
+ pkg_svc => {},
+ primary_svc => '',
+ hidden_svc => {},
+ };
+ if ( $deleted->isa('FS::part_pkg_option') ) {
+ $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
+ } else { # pkg_svc
+ my $svcpart = $deleted->svcpart;
+ $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
+ $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
+ $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
+ }
+ }
+ foreach my $pkgpart (keys %opt) {
+ my $part_pkg = FS::part_pkg->by_key($pkgpart);
+ my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
+ if ( $error ) {
+ die "error recovering damaged pkgpart $pkgpart:\n$error\n";
+ }
+ }
+ } # $bad_upgrade exists
+ else { # do the original upgrade, but correctly this time
+ my @part_pkg = qsearch('part_pkg', {
+ fcc_ds0s => { op => '>', value => 0 },
+ fcc_voip_class => ''
+ });
+ foreach my $part_pkg (@part_pkg) {
+ $part_pkg->set(fcc_voip_class => 2);
+ my @pkg_svc = $part_pkg->pkg_svc;
+ my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
+ my %hidden = map {$_->svcpart, $_->hidden } @pkg_svc;
+ my $error = $part_pkg->replace(
+ $part_pkg->replace_old,
+ options => { $part_pkg->options },
+ pkg_svc => \%quantity,
+ hidden_svc => \%hidden,
+ primary_svc => ($part_pkg->svcpart || ''),
+ );
+ die $error if $error;
+ }
+ }
+ FS::upgrade_journal->set_done($upgrade);
+ }
+
+ # migrate adjourn_months, expire_months, and contract_end_months to
+ # real fields
+ foreach my $field (qw(adjourn_months expire_months contract_end_months)) {
+ foreach my $option (qsearch('part_pkg_option', { optionname => $field })) {
+ my $part_pkg = $option->part_pkg;
+ my $error = $option->delete;
+ if ( $option->optionvalue and $part_pkg->get($field) eq '' ) {
+ $part_pkg->set($field, $option->optionvalue);
+ $error ||= $part_pkg->replace;
+ }
+ die $error if $error;
+ }
+ }
+}
+
+=item curuser_pkgs_sql
+
+Returns an SQL fragment for searching for packages the current user can
+use, either via part_pkg.agentnum directly, or via agent type (see
+L<FS::type_pkgs>).
+
+=cut
+
+sub curuser_pkgs_sql {
+ my $class = shift;
+
+ $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
+
+}
+
+=item agent_pkgs_sql AGENT | AGENTNUM, ...
+
+Returns an SQL fragment for searching for packages the provided agent or agents
+can use, either via part_pkg.agentnum directly, or via agent type (see
+L<FS::type_pkgs>).
+
+=cut
+
+sub agent_pkgs_sql {
+ my $class = shift; #i'm a class method, not a sub (the question is... why??)
+ my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
+
+ $class->_pkgs_sql(@agentnums); #is this why
+
+}
+
+sub _pkgs_sql {
+ my( $class, @agentnums ) = @_;
+ my $agentnums = join(',', @agentnums);
+
+ "
+ (
+ ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
+ OR ( agentnum IS NULL
+ AND EXISTS ( SELECT 1
+ FROM type_pkgs
+ LEFT JOIN agent_type USING ( typenum )
+ LEFT JOIN agent AS typeagent USING ( typenum )
+ WHERE type_pkgs.pkgpart = part_pkg.pkgpart
+ AND typeagent.agentnum IN ($agentnums)
+ )
+ )
+ )
+ ";
+
+}
+
+=item join_options_sql
+
+Returns an SQL fragment for JOINing the part_pkg_option records for this
+package's setup_fee and recur_fee (as setup_option and recur_option,
+respectively). Useful for optimization.
+
+=cut
+
+sub join_options_sql {
+ #my $class = shift;
+ "
+ LEFT JOIN part_pkg_option AS setup_option
+ ON ( part_pkg.pkgpart = setup_option.pkgpart
+ AND setup_option.optionname = 'setup_fee' )
+ LEFT JOIN part_pkg_option AS recur_option
+ ON ( part_pkg.pkgpart = recur_option.pkgpart
+ AND recur_option.optionname = 'recur_fee' )
+ ";
+}
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item plan_info
+
+=cut
+
+#false laziness w/part_export & cdr
+my %info;
+foreach my $INC ( @INC ) {
+ warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
+ foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
+ warn "attempting to load plan info from $file\n" if $DEBUG;
+ $file =~ /\/(\w+)\.pm$/ or do {
+ warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
+ next;
+ };
+ my $mod = $1;
+ my $info = eval "use FS::part_pkg::$mod; ".
+ "\\%FS::part_pkg::$mod\::info;";
+ if ( $@ ) {
+ die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
+ next;
+ }
+ unless ( keys %$info ) {
+ warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
+ next;
+ }
+ warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
+ #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
+ # warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
+ # next;
+ #}
+ $info{$mod} = $info;
+ $info->{'weight'} ||= 0; # quiet warnings
+ }
+}
+
+# copy one level deep to allow replacement of fields and fieldorder
+tie %plans, 'Tie::IxHash',
+ map { my %infohash = %{ $info{$_} };
+ $_ => \%infohash }
+ sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
+ keys %info;
+
+# inheritance of plan options
+foreach my $name (keys(%info)) {
+ if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
+ warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
+ delete $plans{$name};
+ next;
+ }
+ my $parents = $info{$name}->{'inherit_fields'} || [];
+ my (%fields, %field_exists, @fieldorder);
+ foreach my $parent ($name, @$parents) {
+ if ( !exists($info{$parent}) ) {
+ warn "$name tried to inherit from nonexistent '$parent'\n";
+ next;
+ }
+ %fields = ( # avoid replacing existing fields
+ %{ $info{$parent}->{'fields'} || {} },
+ %fields
+ );
+ foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
+ # avoid duplicates
+ next if $field_exists{$_};
+ $field_exists{$_} = 1;
+ # allow inheritors to remove inherited fields from the fieldorder
+ push @fieldorder, $_ if !exists($fields{$_}) or
+ !exists($fields{$_}->{'disabled'});
+ }
+ }
+ $plans{$name}->{'fields'} = \%fields;
+ $plans{$name}->{'fieldorder'} = \@fieldorder;
+}
+
+sub plan_info {
+ \%plans;
+}
+