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:
74 projected date when the quotation will be closed
78 projected confidence (expressed as integer) that quotation will close
88 Creates a new quotation. To add the quotation to the database, see L<"insert">.
90 Note that this stores the hash reference, not a distinct copy of the hash it
91 points to. You can ask the object for a copy with the I<hash> method.
95 sub table { 'quotation'; }
96 sub notice_name { 'Quotation'; }
97 sub template_conf { 'quotation_'; }
98 sub has_sections { 1; }
102 Adds this record to the database. If there is an error, returns the error,
103 otherwise returns false.
107 Delete this record from the database.
109 =item replace OLD_RECORD
111 Replaces the OLD_RECORD with this one in the database. If there is an error,
112 returns the error, otherwise returns false.
116 Checks all fields to make sure this is a valid quotation. If there is
117 an error, returns the error, otherwise returns false. Called by the insert
126 $self->ut_numbern('quotationnum')
127 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
128 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
129 || $self->ut_numbern('_date')
130 || $self->ut_enum('disabled', [ '', 'Y' ])
131 || $self->ut_numbern('usernum')
132 || $self->ut_numbern('close_date')
133 || $self->ut_numbern('confidence')
135 return $error if $error;
137 $self->_date(time) unless $self->_date;
139 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
141 return 'confidence percentage must be an integer between 1 and 100'
142 if length($self->confidence)
143 && ( ($self->confidence < 1) || ($self->confidence > 100) );
145 return 'prospectnum or custnum must be specified'
146 if ! $self->prospectnum
160 sub cust_bill_pkg { #actually quotation_pkg objects
161 shift->quotation_pkg(@_);
170 sprintf('%.2f', $self->_total('setup') + $self->_total('setup_tax'));
173 =item total_recur [ FREQ ]
179 #=item total_recur [ FREQ ]
180 #my $freq = @_ ? shift : '';
181 sprintf('%.2f', $self->_total('recur') + $self->_total('recur_tax'));
185 my( $self, $method ) = @_;
188 $total += $_->$method() for $self->quotation_pkg;
189 sprintf('%.2f', $total);
195 my $opt = shift || {};
196 if ($opt and !ref($opt)) {
197 die ref($self). '->email called with positional parameters';
200 my $conf = $self->conf;
202 my $from = delete $opt->{from};
204 # this is where we set the From: address
205 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
206 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
207 $self->SUPER::email( {
218 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
221 #my $cust_main = $self->cust_main;
222 #my $name = $cust_main->name;
223 #my $name_short = $cust_main->name_short;
224 #my $invoice_number = $self->invnum;
225 #my $invoice_date = $self->_date_pretty;
232 'Quotation-'. $self->quotationnum. '.pdf';
235 =item cust_or_prosect
239 sub cust_or_prospect {
241 $self->custnum ? $self->cust_main : $self->prospect_main;
244 =item cust_or_prospect_label_link
246 HTML links to either the customer or prospect.
248 Returns a list consisting of two elements. The first is a text label for the
249 link, and the second is the URL.
253 sub cust_or_prospect_label_link {
254 my( $self, $p ) = @_;
256 if ( my $custnum = $self->custnum ) {
257 my $display_custnum = $self->cust_main->display_custnum;
258 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
260 : ';show=quotations';
262 emt("View this customer (#[_1])",$display_custnum) =>
263 "${p}view/cust_main.cgi?custnum=$custnum$target"
265 } elsif ( my $prospectnum = $self->prospectnum ) {
267 emt("View this prospect (#[_1])",$prospectnum) =>
268 "${p}view/prospect_main.html?$prospectnum"
276 sub _items_sections {
279 my $escape = $opt{escape}; # the only one we care about
282 my %show; # package frequency => 1 if there's anything to display
283 my %subtotals = (); # package frequency => subtotal
284 my $prorate_total = 0;
285 foreach my $pkg ($self->quotation_pkg) {
287 my $part_pkg = $pkg->part_pkg;
289 my $recur_freq = $part_pkg->freq;
290 $show{$recur_freq} = 1 if $pkg->unitrecur > 0 or $pkg->recur_show_zero;
291 $show{0} = 1 if $pkg->unitsetup > 0 or $pkg->setup_show_zero;
292 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
293 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
295 #this is a shitty hack based on what's in part_pkg/ at the moment
296 # but its good enough for the 99% common case of preventing totals from
297 # displaying for prorate packages
299 if $part_pkg->plan =~ /^(prorate|torrus|agent$)/
300 || $part_pkg->option('recur_method') eq 'prorate'
301 || ( $part_pkg->option('sync_bill_date')
303 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
306 #possible improvement: keep track of flat vs. prorate totals to make the
307 # bottom range more accurate when mixing flat and prorate packages
310 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
313 my $no_recurring = 0;
314 foreach my $freq (keys %subtotals) {
316 #next if $subtotals{$freq} == 0;
317 next if !$show{$freq};
320 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
322 if ( $freq eq '0' ) {
323 if ( scalar(keys(%subtotals)) == 1 ) {
324 # there are no recurring packages
326 $desc = $self->mt('Charges');
328 $desc = $self->mt('Setup Charges');
331 $desc = $self->mt('Recurring Charges') . ' - ' .
332 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
336 'description' => &$escape($desc),
337 'sort_weight' => $weight,
339 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
343 unless ( $no_recurring ) {
345 $total += $_ for values %subtotals;
348 'category' => 'Total category', #required but what's it used for?
351 if ( $prorate_total ) {
355 'description' => 'First payment (depending on day of month)',
356 'subtotal' => [ $subtotals{0}, $total ],
363 'description' => 'First payment',
364 'subtotal' => $total,
370 return \@sections, [];
373 =item enable_previous
377 sub enable_previous { 0 }
379 =item convert_cust_main [ PARAMS ]
381 If this quotation already belongs to a customer, then returns that customer, as
382 an FS::cust_main object.
384 Otherwise, creates a new customer (FS::cust_main object and record, and
385 associated) based on this quotation's prospect, then orders this quotation's
386 packages as real packages for the customer.
388 If there is an error, returns an error message, otherwise, returns the
389 newly-created FS::cust_main object.
391 Accepts the same params as L</order>.
395 sub convert_cust_main {
397 my $params = shift || {};
399 my $cust_main = $self->cust_main;
400 return $cust_main if $cust_main; #already converted, don't again
402 my $oldAutoCommit = $FS::UID::AutoCommit;
403 local $FS::UID::AutoCommit = 0;
406 $cust_main = $self->prospect_main->convert_cust_main;
407 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
408 $dbh->rollback if $oldAutoCommit;
412 $self->prospectnum('');
413 $self->custnum( $cust_main->custnum );
414 my $error = $self->replace || $self->order(undef,$params);
416 $dbh->rollback if $oldAutoCommit;
420 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
426 =item order [ HASHREF ] [ PARAMS ]
428 This method is for use with quotations which are already associated with a customer.
430 Orders this quotation's packages as real packages for the customer.
432 If there is an error, returns an error message, otherwise returns false.
434 If HASHREF is passed, it will be filled with a hash mapping the
435 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
438 If PARAMS hashref is passed, the following params are accepted:
440 onhold - if true, suspends newly ordered packages
446 my $pkgnum_map = shift || {};
447 my $params = shift || {};
448 my $details_map = {};
450 tie my %all_cust_pkg, 'Tie::RefHash';
451 foreach my $quotation_pkg ($self->quotation_pkg) {
452 my $cust_pkg = FS::cust_pkg->new;
453 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
455 # details will be copied below, after package is ordered
456 $details_map->{ $quotation_pkg->quotationpkgnum } = [
457 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
460 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
461 $cust_pkg->set( $_, $quotation_pkg->get($_) );
464 # can now have two discounts each (setup and recur)
465 foreach my $pkg_discount ($quotation_pkg->quotation_pkg_discount) {
466 my $field = $pkg_discount->setuprecur . '_discountnum';
467 $cust_pkg->set($field, $pkg_discount->discountnum);
470 $all_cust_pkg{$cust_pkg} = []; # no services
473 local $SIG{HUP} = 'IGNORE';
474 local $SIG{INT} = 'IGNORE';
475 local $SIG{QUIT} = 'IGNORE';
476 local $SIG{TERM} = 'IGNORE';
477 local $SIG{TSTP} = 'IGNORE';
478 local $SIG{PIPE} = 'IGNORE';
480 my $oldAutoCommit = $FS::UID::AutoCommit;
481 local $FS::UID::AutoCommit = 0;
484 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
487 # copy details (copy_on_order filtering handled above)
488 foreach my $quotationpkgnum (keys %$details_map) {
489 next unless @{$details_map->{$quotationpkgnum}};
490 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
492 @{$details_map->{$quotationpkgnum}}
498 if ($$params{'onhold'}) {
499 foreach my $quotationpkgnum (keys %$pkgnum_map) {
501 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
506 $dbh->rollback if $oldAutoCommit;
510 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
512 foreach my $quotationpkgnum (keys %$pkgnum_map) {
513 # convert the objects to just pkgnums
514 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
515 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
524 One-time charges, like FS::cust_main::charge()
528 #super false laziness w/cust_main::charge
531 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
532 my ( $pkg, $comment, $additional );
533 my ( $setuptax, $taxclass ); #internal taxes
534 my ( $taxproduct, $override ); #vendor (CCH) taxes
536 my $cust_pkg_ref = '';
537 my ( $bill_now, $invoice_terms ) = ( 0, '' );
539 my ( $discountnum, $discountnum_amount, $discountnum_percent ) = ( '','','' );
540 if ( ref( $_[0] ) ) {
541 $amount = $_[0]->{amount};
542 $setup_cost = $_[0]->{setup_cost};
543 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
544 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
545 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
546 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
547 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
548 : '$'. sprintf("%.2f",$amount);
549 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
550 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
551 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
552 $additional = $_[0]->{additional} || [];
553 $taxproduct = $_[0]->{taxproductnum};
554 $override = { '' => $_[0]->{tax_override} };
555 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
556 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
557 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
558 $locationnum = $_[0]->{locationnum};
559 $discountnum = $_->{setup_discountnum};
560 $discountnum_amount = $_->{setup_discountnum_amount};
561 $discountnum_percent = $_->{setup_discountnum_percent};
567 $pkg = @_ ? shift : 'One-time charge';
568 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
570 $taxclass = @_ ? shift : '';
574 local $SIG{HUP} = 'IGNORE';
575 local $SIG{INT} = 'IGNORE';
576 local $SIG{QUIT} = 'IGNORE';
577 local $SIG{TERM} = 'IGNORE';
578 local $SIG{TSTP} = 'IGNORE';
579 local $SIG{PIPE} = 'IGNORE';
581 my $oldAutoCommit = $FS::UID::AutoCommit;
582 local $FS::UID::AutoCommit = 0;
585 my $part_pkg = new FS::part_pkg ( {
587 'comment' => $comment,
591 'classnum' => ( $classnum ? $classnum : '' ),
592 'setuptax' => $setuptax,
593 'taxclass' => $taxclass,
594 'taxproductnum' => $taxproduct,
595 'setup_cost' => $setup_cost,
598 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
599 ( 0 .. @$additional - 1 )
601 'additional_count' => scalar(@$additional),
602 'setup_fee' => $amount,
605 my $error = $part_pkg->insert( options => \%options,
606 tax_overrides => $override,
609 $dbh->rollback if $oldAutoCommit;
613 my $pkgpart = $part_pkg->pkgpart;
616 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
618 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
619 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
620 $error = $type_pkgs->insert;
622 $dbh->rollback if $oldAutoCommit;
627 #except for DIFF, eveything above is idential to cust_main version
628 #but below is our own thing pretty much (adding a quotation package instead
629 # of ordering a customer package, no "bill now")
631 my $quotation_pkg = new FS::quotation_pkg ( {
632 'quotationnum' => $self->quotationnum,
633 'pkgpart' => $pkgpart,
634 'quantity' => $quantity,
635 #'start_date' => $start_date,
636 #'no_auto' => $no_auto,
637 'locationnum' => $locationnum,
638 'setup_discountnum' => $discountnum,
639 'setup_discountnum_amount' => $discountnum_amount,
640 'setup_discountnum_percent' => $discountnum_percent,
643 $error = $quotation_pkg->insert;
645 $dbh->rollback if $oldAutoCommit;
647 #} elsif ( $cust_pkg_ref ) {
648 # ${$cust_pkg_ref} = $cust_pkg;
651 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
658 Disables this quotation (sets disabled to Y, which hides the quotation on
659 prospects and customers).
661 If there is an error, returns an error message, otherwise returns false.
667 $self->disabled('Y');
673 Enables this quotation.
675 If there is an error, returns an error message, otherwise returns false.
687 Calculates current prices for all items on this quotation, including
688 discounts and taxes, and updates the quotation_pkg records accordingly.
694 my $conf = FS::Conf->new;
696 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
698 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
700 my @return_bill = ([]);
703 ###### BEGIN TRANSACTION ######
707 my $temp_dbh = myconnect();
708 local $FS::UID::dbh = $temp_dbh;
709 local $FS::UID::AutoCommit = 0;
711 my $fake_self = FS::quotation->new({ $self->hash });
713 # if this is a prospect, make them into a customer for now
714 # XXX prospects currently can't have service locations
715 my $cust_or_prospect = $self->cust_or_prospect;
717 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
718 $cust_main = $cust_or_prospect->convert_cust_main;
719 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
720 $fake_self->set('prospectnum', '');
721 $fake_self->set('custnum', $cust_main->custnum);
723 $cust_main = $cust_or_prospect;
727 local($FS::cust_pkg::disable_start_on_hold) = 1;
728 $error = $fake_self->order(\%pkgnum_of);
729 die "$error (simulating package order)\n" if $error;
731 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
733 # simulate the first bill
736 'pkg_list' => \@new_pkgs,
737 'time' => time, # an option to adjust this?
738 'return_bill' => $return_bill[0],
739 'no_usage_reset' => 1,
741 $error = $cust_main->bill(%bill_opt);
742 die "$error (simulating initial billing)\n" if $error;
744 # pick dates for future bills
746 foreach (@new_pkgs) {
747 my $bill = $_->get('bill');
749 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
753 foreach my $next_bill (keys %next_bill_pkgs) {
754 $bill_opt{'time'} = $next_bill;
755 $bill_opt{'return_bill'} = $return_bill[$i] = [];
756 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
757 $error = $cust_main->bill(%bill_opt);
758 die "$error (simulating recurring billing cycle $i)\n" if $error;
765 ###### END TRANSACTION ######
766 my %quotationpkgnum_of = reverse %pkgnum_of;
769 warn "pkgnums:\n".Dumper(\%pkgnum_of);
770 warn Dumper(\@return_bill);
773 # Careful: none of the foreign keys in here are correct outside the sandbox.
774 # We have a translation table for pkgnums; all others are total lies.
776 my %quotation_pkg; # quotationpkgnum => quotation_pkg
777 foreach my $qp ($self->quotation_pkg) {
778 $quotation_pkg{$qp->quotationpkgnum} = $qp;
779 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
780 $qp->set('freq', '');
781 # flush old tax records
782 foreach ($qp->quotation_pkg_tax) {
784 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
789 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
790 my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
792 for (my $i = 0; $i < scalar(@return_bill); $i++) {
793 my $this_bill = $return_bill[$i]->[0];
795 warn "$me billing cycle $i produced no invoice\n";
801 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
802 my $pkgnum = $cust_bill_pkg->pkgnum;
803 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
805 # taxes/fees; come back to it
806 push @nonpkg_lines, $cust_bill_pkg;
809 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
810 my $qp = $quotation_pkg{$quotationpkgnum};
812 # XXX supplemental packages could do this (they have separate pkgnums)
813 # handle that special case at some point
814 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
818 # then this is the first (setup) invoice
819 $qp->set('start_date', $cust_bill_pkg->sdate);
820 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
821 # pkgpart_override is a possibility
823 # recurring invoice (should be only one of these per package, though
824 # it may have multiple lineitems with the same pkgnum)
825 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
829 if ( $cust_bill_pkg->get('discounts') ) {
830 # discount records are generated as (setup, recur).
831 # well, not always, sometimes it's just (recur), but fixing this
832 # is horribly invasive.
833 my $discount = $cust_bill_pkg->get('discounts')->[0];
836 # find the quotation_pkg_discount record for this billing pass...
837 my $setuprecur = $i ? 'recur' : 'setup';
838 my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
839 ||= qsearchs('quotation_pkg_discount', {
840 'quotationpkgnum' => $quotationpkgnum,
841 'setuprecur' => $setuprecur,
844 if (!$qpd) { #can't happen
845 warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
848 $qpd->set('amount', $discount->amount);
851 } # end of discount stuff
856 foreach my $cust_bill_pkg (@nonpkg_lines) {
858 my $itemdesc = $cust_bill_pkg->itemdesc;
860 if ($cust_bill_pkg->feepart) {
861 warn "$me simulated bill included a non-package fee (feepart ".
862 $cust_bill_pkg->feepart.")\n";
865 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
866 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
868 # breadth-first unrolled recursion:
869 # take each tax link and any tax-on-tax descendants, and merge them
870 # into a single quotation_pkg_tax record for each pkgnum/taxname
872 while (my $tax_link = shift @$links) {
873 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
874 or die "$me unable to resolve tax link\n";
875 if ($target->pkgnum) {
876 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
877 # create this if there isn't one yet
878 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
879 FS::quotation_pkg_tax->new({
880 quotationpkgnum => $quotationpkgnum,
881 itemdesc => $itemdesc,
885 if ( $i == 0 ) { # first invoice
886 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
887 } else { # subsequent invoices
888 # this isn't perfectly accurate, but that's why it's an estimate
889 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
890 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
891 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
893 } elsif ($target->feepart) {
894 # do nothing; we already warned for the fee itself
896 # tax on tax: the tax target is another tax item.
897 # since this is an estimate, I'm just going to assign it to the
898 # first of the underlying packages. (RT#5243 is why we can't have
900 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
901 if ($sublinks and $sublinks->[0]) {
902 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
903 push @$links, $tax_link; #try again
905 warn "$me unable to assign tax on tax; ignoring\n";
908 } # while my $tax_link
910 } # foreach my $cust_bill_pkg
912 foreach my $quotation_pkg (values %quotation_pkg) {
913 $error = $quotation_pkg->replace;
914 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
917 foreach (values %quotation_pkg_discount) {
918 # { setup => one, recur => another }
919 foreach my $quotation_pkg_discount (values %$_) {
920 $error = $quotation_pkg_discount->replace;
921 return "$error (recording estimated discount)"
925 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
926 $error = $quotation_pkg_tax->insert;
927 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
940 =item search_sql_where HASHREF
942 Class method which returns an SQL WHERE fragment to search for parameters
943 specified in HASHREF. Valid parameters are
949 List reference of start date, end date, as UNIX timestamps.
959 List reference of charged limits (exclusive).
963 List reference of charged limits (exclusive).
967 flag, return open invoices only
971 flag, return net invoices only
979 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
983 sub search_sql_where {
984 my($class, $param) = @_;
986 # warn "$me search_sql_where called with params: \n".
987 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
993 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
994 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
998 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
999 # push @search, "cust_main.refnum = $1";
1003 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
1004 push @search, "quotation.prospectnum = $1";
1008 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
1009 push @search, "cust_bill.custnum = $1";
1013 if ( $param->{_date} ) {
1014 my($beginning, $ending) = @{$param->{_date}};
1016 push @search, "quotation._date >= $beginning",
1017 "quotation._date < $ending";
1021 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
1022 push @search, "quotation.quotationnum >= $1";
1024 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
1025 push @search, "quotation.quotationnum <= $1";
1029 # if ( $param->{charged} ) {
1030 # my @charged = ref($param->{charged})
1031 # ? @{ $param->{charged} }
1032 # : ($param->{charged});
1034 # push @search, map { s/^charged/cust_bill.charged/; $_; }
1038 my $owed_sql = FS::cust_bill->owed_sql;
1041 push @search, "quotation._date < ". (time-86400*$param->{'days'})
1042 if $param->{'days'};
1044 #agent virtualization
1045 my $curuser = $FS::CurrentUser::CurrentUser;
1046 #false laziness w/search/quotation.html
1047 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
1048 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
1051 join(' AND ', @search );
1057 Return line item hashes for each package on this quotation.
1062 my ($self, %options) = @_;
1063 my $escape = $options{'escape_function'};
1064 my $locale = $self->cust_or_prospect->locale;
1066 my $preref = $options{'preref_callback'};
1068 my $section = $options{'section'};
1069 my $freq = $section->{'category'};
1070 my @pkgs = $self->quotation_pkg;
1072 die "_items_pkg called without section->{'category'}"
1073 unless defined $freq;
1075 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
1076 # like we should have done in the first place
1078 foreach my $quotation_pkg (@pkgs) {
1079 my $part_pkg = $quotation_pkg->part_pkg;
1080 my @details = $quotation_pkg->details;
1083 'pkgnum' => $quotation_pkg->quotationpkgnum,
1084 'description' => $quotation_pkg->desc($locale),
1085 'ext_description' => \@details,
1086 'quantity' => $quotation_pkg->quantity,
1090 $setuprecur = 'setup';
1091 if ($part_pkg->freq ne '0') {
1092 # indicate that it's a setup fee on a recur package (cust_bill does
1094 $this_item->{'description'} .= ' Setup';
1097 # recur for this frequency
1098 next if $freq ne $part_pkg->freq;
1099 $setuprecur = 'recur';
1102 $this_item->{'unit_amount'} = sprintf('%.2f',
1103 $quotation_pkg->get('unit'.$setuprecur));
1104 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
1105 * $quotation_pkg->quantity);
1106 next if $this_item->{'amount'} == 0 and !(
1107 $setuprecur eq 'setup'
1108 ? $quotation_pkg->setup_show_zero
1109 : $quotation_pkg->recur_show_zero
1113 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1116 push @items, $this_item;
1117 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1119 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1120 push @items, $discount;
1123 # each quotation_pkg_tax has two amounts: the amount charged on the
1124 # setup invoice, and the amount on the recurring invoice.
1125 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1126 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1128 'description' => $qpt->itemdesc,
1129 'ext_description' => [],
1132 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1134 } # foreach $quotation_pkg
1136 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1137 my $this_tax = $tax_item{$taxname};
1138 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1139 next if $this_tax->{'amount'} == 0;
1140 push @items, $this_tax;
1156 L<FS::Record>, schema.html from the base documentation.