2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
8 use FS::UID qw( dbh myconnect );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearch qsearchs );
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
24 FS::quotation - Object methods for quotation records
30 $record = new FS::quotation \%hash;
31 $record = new FS::quotation { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
43 An FS::quotation object represents a quotation. FS::quotation inherits from
44 FS::Record. The following fields are currently supported:
81 Creates a new quotation. To add the quotation to the database, see L<"insert">.
83 Note that this stores the hash reference, not a distinct copy of the hash it
84 points to. You can ask the object for a copy with the I<hash> method.
88 sub table { 'quotation'; }
89 sub notice_name { 'Quotation'; }
90 sub template_conf { 'quotation_'; }
91 sub has_sections { 1; }
95 Adds this record to the database. If there is an error, returns the error,
96 otherwise returns false.
100 Delete this record from the database.
102 =item replace OLD_RECORD
104 Replaces the OLD_RECORD with this one in the database. If there is an error,
105 returns the error, otherwise returns false.
109 Checks all fields to make sure this is a valid quotation. If there is
110 an error, returns the error, otherwise returns false. Called by the insert
119 $self->ut_numbern('quotationnum')
120 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
121 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
122 || $self->ut_numbern('_date')
123 || $self->ut_enum('disabled', [ '', 'Y' ])
124 || $self->ut_numbern('usernum')
126 return $error if $error;
128 $self->_date(time) unless $self->_date;
130 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
132 return 'prospectnum or custnum must be specified'
133 if ! $self->prospectnum
147 sub cust_bill_pkg { #actually quotation_pkg objects
148 shift->quotation_pkg(@_);
157 sprintf('%.2f', $self->_total('setup') + $self->_total('setup_tax'));
160 =item total_recur [ FREQ ]
166 #=item total_recur [ FREQ ]
167 #my $freq = @_ ? shift : '';
168 sprintf('%.2f', $self->_total('recur') + $self->_total('recur_tax'));
172 my( $self, $method ) = @_;
175 $total += $_->$method() for $self->quotation_pkg;
176 sprintf('%.2f', $total);
182 my $opt = shift || {};
183 if ($opt and !ref($opt)) {
184 die ref($self). '->email called with positional parameters';
187 my $conf = $self->conf;
189 my $from = delete $opt->{from};
191 # this is where we set the From: address
192 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
193 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
194 $self->SUPER::email( {
205 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
208 #my $cust_main = $self->cust_main;
209 #my $name = $cust_main->name;
210 #my $name_short = $cust_main->name_short;
211 #my $invoice_number = $self->invnum;
212 #my $invoice_date = $self->_date_pretty;
217 =item cust_or_prosect
221 sub cust_or_prospect {
223 $self->custnum ? $self->cust_main : $self->prospect_main;
226 =item cust_or_prospect_label_link
228 HTML links to either the customer or prospect.
230 Returns a list consisting of two elements. The first is a text label for the
231 link, and the second is the URL.
235 sub cust_or_prospect_label_link {
236 my( $self, $p ) = @_;
238 if ( my $custnum = $self->custnum ) {
239 my $display_custnum = $self->cust_main->display_custnum;
240 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
242 : ';show=quotations';
244 emt("View this customer (#[_1])",$display_custnum) =>
245 "${p}view/cust_main.cgi?custnum=$custnum$target"
247 } elsif ( my $prospectnum = $self->prospectnum ) {
249 emt("View this prospect (#[_1])",$prospectnum) =>
250 "${p}view/prospect_main.html?$prospectnum"
258 sub _items_sections {
261 my $escape = $opt{escape}; # the only one we care about
263 my %show; # package frequency => 1 if there's anything to display
264 my %subtotals = (); # package frequency => subtotal
265 my $disable_total = 0;
266 foreach my $pkg ($self->quotation_pkg) {
268 my $part_pkg = $pkg->part_pkg;
270 my $recur_freq = $part_pkg->freq;
271 $show{$recur_freq} = 1 if $pkg->unitrecur > 0;
272 $show{0} = 1 if $pkg->unitsetup > 0;
273 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
274 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
276 #this is a shitty hack based on what's in part_pkg/ at the moment
277 # but its good enough for the 99% common case of preventing totals from
278 # displaying for prorate packages
280 if $part_pkg->plan =~ /^(prorate|torrus|agent$)/
281 || $part_pkg->option('recur_method') eq 'prorate'
282 || ( $part_pkg->option('sync_bill_date')
284 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
288 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
291 my $no_recurring = 0;
292 foreach my $freq (keys %subtotals) {
294 #next if $subtotals{$freq} == 0;
295 next if !$show{$freq};
298 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
300 if ( $freq eq '0' ) {
301 if ( scalar(keys(%subtotals)) == 1 ) {
302 # there are no recurring packages
304 $desc = $self->mt('Charges');
306 $desc = $self->mt('Setup Charges');
309 $desc = $self->mt('Recurring Charges') . ' - ' .
310 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
314 'description' => &$escape($desc),
315 'sort_weight' => $weight,
317 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
321 unless ( $disable_total || $no_recurring ) {
323 $total += $_ for values %subtotals;
325 'description' => 'First payment',
327 'category' => 'Total category', #required but what's it used for?
328 'subtotal' => sprintf('%.2f',$total)
332 return \@sections, [];
335 =item enable_previous
339 sub enable_previous { 0 }
341 =item convert_cust_main
343 If this quotation already belongs to a customer, then returns that customer, as
344 an FS::cust_main object.
346 Otherwise, creates a new customer (FS::cust_main object and record, and
347 associated) based on this quotation's prospect, then orders this quotation's
348 packages as real packages for the customer.
350 If there is an error, returns an error message, otherwise, returns the
351 newly-created FS::cust_main object.
355 sub convert_cust_main {
358 my $cust_main = $self->cust_main;
359 return $cust_main if $cust_main; #already converted, don't again
361 my $oldAutoCommit = $FS::UID::AutoCommit;
362 local $FS::UID::AutoCommit = 0;
365 $cust_main = $self->prospect_main->convert_cust_main;
366 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
367 $dbh->rollback if $oldAutoCommit;
371 $self->prospectnum('');
372 $self->custnum( $cust_main->custnum );
373 my $error = $self->replace || $self->order;
375 $dbh->rollback if $oldAutoCommit;
379 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
385 =item order [ HASHREF ]
387 This method is for use with quotations which are already associated with a customer.
389 Orders this quotation's packages as real packages for the customer.
391 If there is an error, returns an error message, otherwise returns false.
393 If HASHREF is passed, it will be filled with a hash mapping the
394 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
401 my $pkgnum_map = shift || {};
403 tie my %all_cust_pkg, 'Tie::RefHash';
404 foreach my $quotation_pkg ($self->quotation_pkg) {
405 my $cust_pkg = FS::cust_pkg->new;
406 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
408 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
409 $cust_pkg->set( $_, $quotation_pkg->get($_) );
412 # can now have two discounts each (setup and recur)
413 foreach my $pkg_discount ($quotation_pkg->quotation_pkg_discount) {
414 my $field = $pkg_discount->setuprecur . '_discountnum';
415 $cust_pkg->set($field, $pkg_discount->discountnum);
418 $all_cust_pkg{$cust_pkg} = []; # no services
421 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
423 foreach my $quotationpkgnum (keys %$pkgnum_map) {
424 # convert the objects to just pkgnums
425 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
426 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
434 One-time charges, like FS::cust_main::charge()
438 #super false laziness w/cust_main::charge
441 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
442 my ( $pkg, $comment, $additional );
443 my ( $setuptax, $taxclass ); #internal taxes
444 my ( $taxproduct, $override ); #vendor (CCH) taxes
446 my $cust_pkg_ref = '';
447 my ( $bill_now, $invoice_terms ) = ( 0, '' );
449 if ( ref( $_[0] ) ) {
450 $amount = $_[0]->{amount};
451 $setup_cost = $_[0]->{setup_cost};
452 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
453 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
454 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
455 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
456 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
457 : '$'. sprintf("%.2f",$amount);
458 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
459 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
460 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
461 $additional = $_[0]->{additional} || [];
462 $taxproduct = $_[0]->{taxproductnum};
463 $override = { '' => $_[0]->{tax_override} };
464 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
465 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
466 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
467 $locationnum = $_[0]->{locationnum};
473 $pkg = @_ ? shift : 'One-time charge';
474 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
476 $taxclass = @_ ? shift : '';
480 local $SIG{HUP} = 'IGNORE';
481 local $SIG{INT} = 'IGNORE';
482 local $SIG{QUIT} = 'IGNORE';
483 local $SIG{TERM} = 'IGNORE';
484 local $SIG{TSTP} = 'IGNORE';
485 local $SIG{PIPE} = 'IGNORE';
487 my $oldAutoCommit = $FS::UID::AutoCommit;
488 local $FS::UID::AutoCommit = 0;
491 my $part_pkg = new FS::part_pkg ( {
493 'comment' => $comment,
497 'classnum' => ( $classnum ? $classnum : '' ),
498 'setuptax' => $setuptax,
499 'taxclass' => $taxclass,
500 'taxproductnum' => $taxproduct,
501 'setup_cost' => $setup_cost,
504 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
505 ( 0 .. @$additional - 1 )
507 'additional_count' => scalar(@$additional),
508 'setup_fee' => $amount,
511 my $error = $part_pkg->insert( options => \%options,
512 tax_overrides => $override,
515 $dbh->rollback if $oldAutoCommit;
519 my $pkgpart = $part_pkg->pkgpart;
522 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
524 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
525 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
526 $error = $type_pkgs->insert;
528 $dbh->rollback if $oldAutoCommit;
533 #except for DIFF, eveything above is idential to cust_main version
534 #but below is our own thing pretty much (adding a quotation package instead
535 # of ordering a customer package, no "bill now")
537 my $quotation_pkg = new FS::quotation_pkg ( {
538 'quotationnum' => $self->quotationnum,
539 'pkgpart' => $pkgpart,
540 'quantity' => $quantity,
541 #'start_date' => $start_date,
542 #'no_auto' => $no_auto,
543 'locationnum'=> $locationnum,
546 $error = $quotation_pkg->insert;
548 $dbh->rollback if $oldAutoCommit;
550 #} elsif ( $cust_pkg_ref ) {
551 # ${$cust_pkg_ref} = $cust_pkg;
554 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
561 Disables this quotation (sets disabled to Y, which hides the quotation on
562 prospects and customers).
564 If there is an error, returns an error message, otherwise returns false.
570 $self->disabled('Y');
576 Enables this quotation.
578 If there is an error, returns an error message, otherwise returns false.
590 Calculates current prices for all items on this quotation, including
591 discounts and taxes, and updates the quotation_pkg records accordingly.
597 my $conf = FS::Conf->new;
599 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
601 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
603 my @return_bill = ([]);
606 ###### BEGIN TRANSACTION ######
610 my $temp_dbh = myconnect();
611 local $FS::UID::dbh = $temp_dbh;
612 local $FS::UID::AutoCommit = 0;
614 my $fake_self = FS::quotation->new({ $self->hash });
616 # if this is a prospect, make them into a customer for now
617 # XXX prospects currently can't have service locations
618 my $cust_or_prospect = $self->cust_or_prospect;
620 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
621 $cust_main = $cust_or_prospect->convert_cust_main;
622 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
623 $fake_self->set('prospectnum', '');
624 $fake_self->set('custnum', $cust_main->custnum);
626 $cust_main = $cust_or_prospect;
630 $error = $fake_self->order(\%pkgnum_of);
631 die "$error (simulating package order)\n" if $error;
633 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
635 # simulate the first bill
638 'pkg_list' => \@new_pkgs,
639 'time' => time, # an option to adjust this?
640 'return_bill' => $return_bill[0],
641 'no_usage_reset' => 1,
643 $error = $cust_main->bill(%bill_opt);
644 die "$error (simulating initial billing)\n" if $error;
646 # pick dates for future bills
648 foreach (@new_pkgs) {
649 my $bill = $_->get('bill');
651 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
655 foreach my $next_bill (keys %next_bill_pkgs) {
656 $bill_opt{'time'} = $next_bill;
657 $bill_opt{'return_bill'} = $return_bill[$i] = [];
658 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
659 $error = $cust_main->bill(%bill_opt);
660 die "$error (simulating recurring billing cycle $i)\n" if $error;
667 ###### END TRANSACTION ######
668 my %quotationpkgnum_of = reverse %pkgnum_of;
671 warn "pkgnums:\n".Dumper(\%pkgnum_of);
672 warn Dumper(\@return_bill);
675 # Careful: none of the foreign keys in here are correct outside the sandbox.
676 # We have a translation table for pkgnums; all others are total lies.
678 my %quotation_pkg; # quotationpkgnum => quotation_pkg
679 foreach my $qp ($self->quotation_pkg) {
680 $quotation_pkg{$qp->quotationpkgnum} = $qp;
681 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
682 $qp->set('freq', '');
683 # flush old tax records
684 foreach ($qp->quotation_pkg_tax) {
686 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
691 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
692 my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
694 for (my $i = 0; $i < scalar(@return_bill); $i++) {
695 my $this_bill = $return_bill[$i]->[0];
697 warn "$me billing cycle $i produced no invoice\n";
703 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
704 my $pkgnum = $cust_bill_pkg->pkgnum;
705 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
707 # taxes/fees; come back to it
708 push @nonpkg_lines, $cust_bill_pkg;
711 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
712 my $qp = $quotation_pkg{$quotationpkgnum};
714 # XXX supplemental packages could do this (they have separate pkgnums)
715 # handle that special case at some point
716 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
720 # then this is the first (setup) invoice
721 $qp->set('start_date', $cust_bill_pkg->sdate);
722 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
723 # pkgpart_override is a possibility
725 # recurring invoice (should be only one of these per package, though
726 # it may have multiple lineitems with the same pkgnum)
727 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
731 if ( $cust_bill_pkg->get('discounts') ) {
732 # discount records are generated as (setup, recur).
733 # well, not always, sometimes it's just (recur), but fixing this
734 # is horribly invasive.
735 my $discount = $cust_bill_pkg->get('discounts')->[0];
738 # find the quotation_pkg_discount record for this billing pass...
739 my $setuprecur = $i ? 'recur' : 'setup';
740 my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
741 ||= qsearchs('quotation_pkg_discount', {
742 'quotationpkgnum' => $quotationpkgnum,
743 'setuprecur' => $setuprecur,
746 if (!$qpd) { #can't happen
747 warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
750 $qpd->set('amount', $discount->amount);
753 } # end of discount stuff
758 foreach my $cust_bill_pkg (@nonpkg_lines) {
760 my $itemdesc = $cust_bill_pkg->itemdesc;
762 if ($cust_bill_pkg->feepart) {
763 warn "$me simulated bill included a non-package fee (feepart ".
764 $cust_bill_pkg->feepart.")\n";
767 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
768 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
770 # breadth-first unrolled recursion:
771 # take each tax link and any tax-on-tax descendants, and merge them
772 # into a single quotation_pkg_tax record for each pkgnum/taxname
774 while (my $tax_link = shift @$links) {
775 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
776 or die "$me unable to resolve tax link\n";
777 if ($target->pkgnum) {
778 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
779 # create this if there isn't one yet
780 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
781 FS::quotation_pkg_tax->new({
782 quotationpkgnum => $quotationpkgnum,
783 itemdesc => $itemdesc,
787 if ( $i == 0 ) { # first invoice
788 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
789 } else { # subsequent invoices
790 # this isn't perfectly accurate, but that's why it's an estimate
791 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
792 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
793 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
795 } elsif ($target->feepart) {
796 # do nothing; we already warned for the fee itself
798 # tax on tax: the tax target is another tax item.
799 # since this is an estimate, I'm just going to assign it to the
800 # first of the underlying packages. (RT#5243 is why we can't have
802 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
803 if ($sublinks and $sublinks->[0]) {
804 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
805 push @$links, $tax_link; #try again
807 warn "$me unable to assign tax on tax; ignoring\n";
810 } # while my $tax_link
812 } # foreach my $cust_bill_pkg
814 foreach my $quotation_pkg (values %quotation_pkg) {
815 $error = $quotation_pkg->replace;
816 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
819 foreach (values %quotation_pkg_discount) {
820 # { setup => one, recur => another }
821 foreach my $quotation_pkg_discount (values %$_) {
822 $error = $quotation_pkg_discount->replace;
823 return "$error (recording estimated discount)"
827 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
828 $error = $quotation_pkg_tax->insert;
829 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
842 =item search_sql_where HASHREF
844 Class method which returns an SQL WHERE fragment to search for parameters
845 specified in HASHREF. Valid parameters are
851 List reference of start date, end date, as UNIX timestamps.
861 List reference of charged limits (exclusive).
865 List reference of charged limits (exclusive).
869 flag, return open invoices only
873 flag, return net invoices only
881 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
885 sub search_sql_where {
886 my($class, $param) = @_;
888 # warn "$me search_sql_where called with params: \n".
889 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
895 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
896 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
900 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
901 # push @search, "cust_main.refnum = $1";
905 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
906 push @search, "quotation.prospectnum = $1";
910 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
911 push @search, "cust_bill.custnum = $1";
915 if ( $param->{_date} ) {
916 my($beginning, $ending) = @{$param->{_date}};
918 push @search, "quotation._date >= $beginning",
919 "quotation._date < $ending";
923 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
924 push @search, "quotation.quotationnum >= $1";
926 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
927 push @search, "quotation.quotationnum <= $1";
931 # if ( $param->{charged} ) {
932 # my @charged = ref($param->{charged})
933 # ? @{ $param->{charged} }
934 # : ($param->{charged});
936 # push @search, map { s/^charged/cust_bill.charged/; $_; }
940 my $owed_sql = FS::cust_bill->owed_sql;
943 push @search, "quotation._date < ". (time-86400*$param->{'days'})
946 #agent virtualization
947 my $curuser = $FS::CurrentUser::CurrentUser;
948 #false laziness w/search/quotation.html
949 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
950 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
953 join(' AND ', @search );
959 Return line item hashes for each package on this quotation.
964 my ($self, %options) = @_;
965 my $escape = $options{'escape_function'};
966 my $locale = $self->cust_or_prospect->locale;
968 my $preref = $options{'preref_callback'};
970 my $section = $options{'section'};
971 my $freq = $section->{'category'};
972 my @pkgs = $self->quotation_pkg;
974 die "_items_pkg called without section->{'category'}"
975 unless defined $freq;
977 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
978 # like we should have done in the first place
980 foreach my $quotation_pkg (@pkgs) {
981 my $part_pkg = $quotation_pkg->part_pkg;
984 'pkgnum' => $quotation_pkg->quotationpkgnum,
985 'description' => $quotation_pkg->desc($locale),
986 'ext_description' => [],
987 'quantity' => $quotation_pkg->quantity,
991 $setuprecur = 'setup';
992 if ($part_pkg->freq ne '0') {
993 # indicate that it's a setup fee on a recur package (cust_bill does
995 $this_item->{'description'} .= ' Setup';
998 # recur for this frequency
999 next if $freq ne $part_pkg->freq;
1000 $setuprecur = 'recur';
1003 $this_item->{'unit_amount'} = sprintf('%.2f',
1004 $quotation_pkg->get('unit'.$setuprecur));
1005 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
1006 * $quotation_pkg->quantity);
1007 next if $this_item->{'amount'} == 0;
1010 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1013 push @items, $this_item;
1014 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1016 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1017 push @items, $discount;
1020 # each quotation_pkg_tax has two amounts: the amount charged on the
1021 # setup invoice, and the amount on the recurring invoice.
1022 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1023 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1025 'description' => $qpt->itemdesc,
1026 'ext_description' => [],
1029 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1031 } # foreach $quotation_pkg
1033 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1034 my $this_tax = $tax_item{$taxname};
1035 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1036 next if $this_tax->{'amount'} == 0;
1037 push @items, $this_tax;
1053 L<FS::Record>, schema.html from the base documentation.